You're using an old browser that may not display this page correctly.

This blog contains a Legacy version designed to work with browsers dating as far back as the late 1990s.

Create a music player with only HTML5 (no JavaScript)


This article was ported from my original blog, but it’s been updated to reflect my current blog’s technology.

I’ve always wanted to include a music player into my blog to add some spice to the reading experience. The main problem towards this was the fact that my blog is a no-JavaScript-friendly website, so I wasn’t very inclined on the idea of attaching more <script> tags to my layouts.

That’s when I came up with the idea of using the browser’s built-in “players” by exploiting the functionality of iframes. And best of all is that it doesn’t require you to host the music yourself!

The basic implementation

This is all you need to implement a music player for your static website.

<!-- Give your iframe a name attribute. This will be used by your links. -->
<iframe name="player" title="Music player test iframe" allow="autoplay"></iframe>

<!-- Add links to the music. Make sure to put your target attribute that points to the iframe! -->
<a href="https://url.to/your/song1.mp3" target="player">Track 1</a>
<a href="https://url.to/your/song2.mp3" target="player">Track 2</a>
<!-- Give your iframe a name attribute. This will be used by your links. -->
<iframe name="player" title="Music player test iframe" allow="autoplay"></iframe>

<!-- Add links to the music. Make sure to put your target attribute that points to the iframe! -->
<a href="https://url.to/your/song1.mp3" target="player">Track 1</a>
<a href="https://url.to/your/song2.mp3" target="player">Track 2</a>
<!-- Give your iframe a name attribute. This will be used by your links. -->
<iframe name="player" title="Music player test iframe" allow="autoplay"></iframe>

<!-- Add links to the music. Make sure to put your target attribute that points to the iframe! -->
<a href="https://url.to/your/song1.mp3" target="player">Track 1</a>
<a href="https://url.to/your/song2.mp3" target="player">Track 2</a>

Try disabling JavaScript in your browser and check the magic happen in this sample:

However, some of you might have noticed a couple of oddities:

Examining the iframe’s HTML will reveal what’s happening: The browser has rendered in a <video> element… But why?

Here’s a fun fact: Did you know that <video> also supports audio only playback to some extent?

At first, the browser is simply rendering a video element. When it finds out it’s only audio, it automatically replaces it with an audio player on its own, and we have no control over the attributes of either element.

In Firefox-based browsers, they either do this seamlessly, or the player actually supports both types of media, but this doesn’t make it less annoying.

yonic

As you can see, this thing isn’t entirely cross-browser. Also, the music will not loop by default, which might be useful for lengthy posts with a music that might not last that long. The only way to have full control of this is to have a page with an audio component of our making, and not one just magically spawned by the browser.

Making your own audio component

You’re going to need your own HTML document that contains only your <audio> element. This is what’s going to be rendered inside your iframe.

<html>
    <body>
        <audio controls autoplay loop>
            <source src="https://url.to/your/song1.mp3" type="audio/mpeg">
        </audio>
    </body>
</html>
<html>
    <body>
        <audio controls autoplay loop>
            <source src="https://url.to/your/song1.mp3" type="audio/mpeg">
        </audio>
    </body>
</html>
<html>
    <body>
        <audio controls autoplay loop>
            <source src="https://url.to/your/song1.mp3" type="audio/mpeg">
        </audio>
    </body>
</html>

And then, in your main page, have a link that points toward that HTML document.

<!-- This is in an article of yours -->
<a href="https://mysite.com/audio/song1" target="player">Track 1</a>
<a href="https://mysite.com/audio/song2" target="player">Track 2</a>
<!-- This is in an article of yours -->
<a href="https://mysite.com/audio/song1" target="player">Track 1</a>
<a href="https://mysite.com/audio/song2" target="player">Track 2</a>
<!-- This is in an article of yours -->
<a href="https://mysite.com/audio/song1" target="player">Track 1</a>
<a href="https://mysite.com/audio/song2" target="player">Track 2</a>

Note though, that this is just the resulting HTML. You will have to resort to your CMS or HTML rendering capabilities of your SSG to make it output something close to this.

In the SSG I use for my blog (Astro) for example, you can create a dynamic route that pulls in the information from a list of songs. This list of songs can harvest the power of content collections, but if you’re not using Astro 2.0 you can import a JSON file with all of the songs.

Below is an example of an implementation close to the one I use on Yonic Corner. First, I define the schema that the JSON music data files will use with Zod.

ts src/content/config.ts
import { z, defineCollection } from 'astro:content';

const musicCollection = defineCollection({
    type: "data",
    schema: z.object({
        sources: z.array(z.object({
            src: z.string().url(),
            type: z.string() // You can add .regex() to check it's formatted as a MIME type
        }))
    }).strict()
})

export const collections = {
  music: musicCollection,
};
ts src/content/config.ts
import { z, defineCollection } from 'astro:content';

const musicCollection = defineCollection({
    type: "data",
    schema: z.object({
        sources: z.array(z.object({
            src: z.string().url(),
            type: z.string() // You can add .regex() to check it's formatted as a MIME type
        }))
    }).strict()
})

export const collections = {
  music: musicCollection,
};
ts src/content/config.ts
import { z, defineCollection } from 'astro:content';

const musicCollection = defineCollection({
    type: "data",
    schema: z.object({
        sources: z.array(z.object({
            src: z.string().url(),
            type: z.string() // You can add .regex() to check it's formatted as a MIME type
        }))
    }).strict()
})

export const collections = {
  music: musicCollection,
};

Then, I set the route that will render the player with the desired music.

astro src/pages/player/[...song].astro
---
import { getCollection, type CollectionEntry } from "astro:content";

export async function getStaticPaths() {
    const songList = await getCollection("music");
    return songList.map(song => {
        params: { song: song.id.replaceAll("\\", "/") } // Replace Windows backslashes with forward slashes
        props: { song: song.data }
    })
}

// Get the data for the song selected with the song URL param
const { sources } = Astro.props.song;

// Types for Astro.props
interface Props {
    song: CollectionEntry<"music">["data"]
}

---
<audio controls autoplay loop>
    { sources.map(source => <source src={source.src} type={source.type} /> ) }
</audio>
astro src/pages/player/[...song].astro
---
import { getCollection, type CollectionEntry } from "astro:content";

export async function getStaticPaths() {
    const songList = await getCollection("music");
    return songList.map(song => {
        params: { song: song.id.replaceAll("\\", "/") } // Replace Windows backslashes with forward slashes
        props: { song: song.data }
    })
}

// Get the data for the song selected with the song URL param
const { sources } = Astro.props.song;

// Types for Astro.props
interface Props {
    song: CollectionEntry<"music">["data"]
}

---
<audio controls autoplay loop>
    { sources.map(source => <source src={source.src} type={source.type} /> ) }
</audio>
astro src/pages/player/[...song].astro
---
import { getCollection, type CollectionEntry } from "astro:content";

export async function getStaticPaths() {
    const songList = await getCollection("music");
    return songList.map(song => {
        params: { song: song.id.replaceAll("\\", "/") } // Replace Windows backslashes with forward slashes
        props: { song: song.data }
    })
}

// Get the data for the song selected with the song URL param
const { sources } = Astro.props.song;

// Types for Astro.props
interface Props {
    song: CollectionEntry<"music">["data"]
}

---
<audio controls autoplay loop>
    { sources.map(source => <source src={source.src} type={source.type} /> ) }
</audio>

And then, create the component that will serve as a link to load a song into the player.

astro src/components/PlayerLink.astro
---
import { getEntry } from "astro:content";
import { getImage } from "astro:assets";
import path from "path";

interface Props {
    id: string;
}

// We need to process the id's slashes to the correct ones per platform
// Even if Windows supports forward slashes,
// Astro uses backslashes for the content ids in Windows.
const osPath = process.platform === 'win32' ? path["win32"] : path["posix"];
const id = Astro.props.id.split("/").join(osPath.sep);

const {id: musicId, data: music} = (await getEntry('music', id))!;
---
<a href={`/player/${musicId.replaceAll("\\","/")}`}>
    <!-- use the data property here for metadata -->
    <p>{data.title}</p>
</a>
astro src/components/PlayerLink.astro
---
import { getEntry } from "astro:content";
import { getImage } from "astro:assets";
import path from "path";

interface Props {
    id: string;
}

// We need to process the id's slashes to the correct ones per platform
// Even if Windows supports forward slashes,
// Astro uses backslashes for the content ids in Windows.
const osPath = process.platform === 'win32' ? path["win32"] : path["posix"];
const id = Astro.props.id.split("/").join(osPath.sep);

const {id: musicId, data: music} = (await getEntry('music', id))!;
---
<a href={`/player/${musicId.replaceAll("\\","/")}`}>
    <!-- use the data property here for metadata -->
    <p>{data.title}</p>
</a>
astro src/components/PlayerLink.astro
---
import { getEntry } from "astro:content";
import { getImage } from "astro:assets";
import path from "path";

interface Props {
    id: string;
}

// We need to process the id's slashes to the correct ones per platform
// Even if Windows supports forward slashes,
// Astro uses backslashes for the content ids in Windows.
const osPath = process.platform === 'win32' ? path["win32"] : path["posix"];
const id = Astro.props.id.split("/").join(osPath.sep);

const {id: musicId, data: music} = (await getEntry('music', id))!;
---
<a href={`/player/${musicId.replaceAll("\\","/")}`}>
    <!-- use the data property here for metadata -->
    <p>{data.title}</p>
</a>

Finally, all you have to do is to place the data in a JSON inside the content folder.

json src/content/music/test/song.json
{
    "title": "Test song",
    "sources": [
        {
            "src": "https://cdn.freesound.org/previews/251/251461_4146089-lq.ogg",
            "type": "audio/ogg"
        },
        {
            "src": "https://cdn.freesound.org/previews/251/251461_4146089-lq.mp3",
            "type": "audio/mpeg"
        }
    ]
}
json src/content/music/test/song.json
{
    "title": "Test song",
    "sources": [
        {
            "src": "https://cdn.freesound.org/previews/251/251461_4146089-lq.ogg",
            "type": "audio/ogg"
        },
        {
            "src": "https://cdn.freesound.org/previews/251/251461_4146089-lq.mp3",
            "type": "audio/mpeg"
        }
    ]
}
json src/content/music/test/song.json
{
    "title": "Test song",
    "sources": [
        {
            "src": "https://cdn.freesound.org/previews/251/251461_4146089-lq.ogg",
            "type": "audio/ogg"
        },
        {
            "src": "https://cdn.freesound.org/previews/251/251461_4146089-lq.mp3",
            "type": "audio/mpeg"
        }
    ]
}

Now you can use subdirectories within the content/music folder to organize the JSON data files in a more intuitive way, and add the player link component anywhere like this:

<PlayerLink id="test/song" />
<PlayerLink id="test/song" />
<PlayerLink id="test/song" />

This is the point where you can really start controlling your media playback: You may add a few extra elements, add some style to the player (outside the actual player controls, that is), you can even add multiple fallback sources.

The following link will play music on the blog’s music player —if you don’t see it, don’t worry, it’ll be explained in a bit.

If JavaScript is enabled, the player should fly out automatically. If not, a tab will show up in the left side of the page, and you can mouse over it to make it fly out.

The player even works on IE! Although the fly out transition doesn’t work, as the UI framework I use (Svelte) does not work on IE, and any error in the scripts will cause the execution of everything else to halt. So frustrating!

Limitations

You may have noticed that sometimes in Chrome, it may not play automatically. Chromium-based browsers have a few restrictive policies when it comes to autoplay. However, now that we’ve essentially moved the domain where the player was created to our own site, clicking on the link already counts as an interaction with the domain, leaving the mysterious Media Engagement Index the only restriction that could prevent the audio from playing automatically. Just leaving it playing for a few seconds should be enough to allow autoplay. And we don’t even need the allow="autoplay" attribute in the iframe anymore!

If you’re on mobile, you may have noticed there was no music link to begin with. That’s because I’ve disabled the links and the player on mobile, for two reasons:

  1. Mobile browsers automatically disable autoplay. Users have to explicitly tell the browser to play any kind of media, including audio, which makes the “tap to play music” mechanic pretty much pointless.
  2. Listening to music (or anything, really) while reading is a terrible idea while you’re moving. Your sense of sight is focused on reading, leaving your other three useful senses to know your way around. Now imagine your hearing sense doing the same with music: Now you can only know your surroundings by touch (where you place your steps) and smell, which are very limited in range compared to sight or hearing. I’m not even taking into account taste, that’s just downright awkward.
Someone looking at the phone about to meet its end
Watch and hear where you’re going!

Audio will still play even when the iframe is not displayed, so I removed the links when you’re on mobile to prevent audio being played at all, as well as the player itself, saving precious display space.

This technique may also be slightly less performant than a JavaScript audio player, as it will have many more HTTP requests. This is unavoidable, unfortunately —you’re basically loading different pages with a different audio every time. It all depends on the metrics of your server and whether you’re using a heavy JavaScript library for your player.

Extending functionality with JavaScript

Obviously, the main drawback of avoiding JavaScript altogether is that we lose access to any extensibility in which JavaScript might be necessary, like AJAX and the video and audio APIs. Like most no-JS projects, you can easily extend functionality in three ways:

  1. Replace the no-JS player with a version with JavaScript, and only display the no-JS one with <noscript> tags.
  2. Alter the no-JS player to extend functionality through events, and have a basic fallback state inside <noscript> tags. For better results, wrap the iframe in a mutable HTML element.
  3. Add elements that will only work with JavaScript, but aren’t necessary for the basic functionality. They may probably belong better outside the iframe.

For example, you may think that a playlist may be impossible to have without JS, but you could always print out a list of all tracks which are plain HTML links that work just like the ones from before (1).

The main drawbacks are that it won’t be able to track the position within the playlist nor switch to the next track after the current one has finished, but at least you have a list of all available tracks. But perhaps you could do that as a separate JS-only component (3).

It all depends on your wished functionalities. In the case of my blog, I’m just fine with showing the music that’s playing, so I can always modify the visuals of the “widget” that wraps the actual player with JavaScript, so long as it still remains functional with JavaScript disabled (2).

Why would you do this?

It makes perfect sense that you’d want to make interactable components with JavaScript, but there are valid arguments to also want a JavaScript free browsing experience.

The current trends seem to sail towards a simpler web, built in a similar way as it was done back in the old days, but with modern technology. Gone are the days where single page applications ruled the web software landscape, and CMSs and SSGs have gone a long way to grow as powerful and as competent as Wordpress.

JavaScript may seem timeless, but it’s evolving constantly; unlike HTML, which hasn’t changed munch since HTML5 was first drafted waaaay back in 2008. While adopting to a specific JavaScript standard might require you to change many lines of code, you can do the same in HTML5 by changing a few tags, since the browser usually does the heavy lifting in its own code.

Also, it can be a very interesting journey to go out of the norm and investigate some of the HTML obscura and use it creatively to achieve similar results. I highly recommend you to at least delve in it and attempt to learn another style of web development.

Music

Off
Music