HTML Formatterhtmlformatteronline.com

Styled audio player

A custom player over the native audio element — keyboard accessible, no library.

embedaudioplayerpodcastmedia
<figure class="player" id="player">
  <figcaption>
    <strong>Episode 14 — Why linen creases</strong>
    <span>The Acme Journal · 24 min</span>
  </figcaption>

  <audio id="audio" preload="metadata"
         src="https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"></audio>

  <div class="controls">
    <button type="button" id="toggle" aria-label="Play">
      <svg class="i-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
      <svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" hidden><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/></svg>
    </button>

    <span class="time" id="now">0:00</span>

    <label class="sr" for="seek">Seek</label>
    <input type="range" id="seek" value="0" min="0" max="100" step="0.1">

    <span class="time" id="dur">0:00</span>

    <button type="button" id="rate" aria-label="Playback speed">1&times;</button>
  </div>
</figure>

How it works

The native element does the work. <audio> without controls is invisible but fully functional: it loads, decodes, buffers, seeks and fires events. This is a skin over it, not a reimplementation.

The UI follows the element, not the click. The play and pause icons swap on the audio element's own play and pause events rather than inside the click handler. That matters because playback can stop for reasons you did not cause — a phone call, another tab taking the audio focus, the media keys on a keyboard. Listening to the element keeps the interface honest.

The scrubber is a real range input. Restyling <input type="range"> is fiddlier than building a div with a drag handler, and it means keyboard users get arrow keys, Home and End for free, and screen readers announce the value. That trade is always worth it.

The filled portion is a gradient, not a second element. A linear-gradient sized by a custom property paints the played section, so there is no overlay div to keep in sync with the thumb.

The drag guard. if (seek.matches(":active")) return stops timeupdate yanking the handle back while someone is dragging it. Without it, scrubbing feels like it is fighting you.

preload="metadata" fetches the duration and not the audio, which is a few kilobytes rather than several megabytes. Use none if you have many players on a page.

Accessibility notes

The play button's aria-label changes with the state, so it always says what pressing it will do.

The range input has a real label, visually hidden. Screen readers announce it as a slider with a percentage, and arrow keys move it.

Time readouts use tabular-nums, so the layout does not shift as the digits change — a small thing that is very noticeable once you see it.

Do not autoplay audio. Browsers block it, and it is hostile to screen reader users, for whom unexpected sound competes directly with the speech they are relying on.

Making it yours

Add a <source> element per format if you need broad support — MP3 works everywhere, Opus in an Ogg container is smaller and is supported by everything current.

For a podcast, put the episode's description and a download link next to the player in real text. Search engines and people with the audio muted both need it, and it is what feeds a PodcastEpisode markup block.

For a waveform, the Web Audio API can generate one, but it means downloading the whole file to analyse it. Pre-render the waveform as an image at publish time instead.

Related templates

Check your version

Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.