Styled audio player
A custom player over the native audio element — keyboard accessible, no library.
<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×</button>
</div>
</figure>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 24px; background: #fff; }
.player {
max-width: 520px;
margin: 0 auto;
padding: 20px 22px 18px;
border: 1px solid #e2e7ef;
border-radius: 14px;
background: #fff;
}
figcaption { margin-bottom: 16px; }
figcaption strong { display: block; font-size: .98rem; color: #16202e; }
figcaption span { font-size: .83rem; color: #8593ab; }
.controls { display: flex; align-items: center; gap: 12px; }
.controls button {
flex-shrink: 0;
display: grid;
place-items: center;
width: 42px; height: 42px;
font: inherit;
font-size: .82rem;
border: 1px solid #e2e7ef;
border-radius: 50%;
background: #3b4fe4;
color: #fff;
cursor: pointer;
border-color: #3b4fe4;
}
.controls button:hover { background: #2a3abf; border-color: #2a3abf; }
.controls button:focus-visible { outline: 2px solid #3b4fe4; outline-offset: 2px; }
#rate {
width: 38px; height: 32px;
border-radius: 8px;
background: #fff;
color: #5b6b83;
border-color: #e2e7ef;
}
#rate:hover { background: #f2f5f9; color: #16202e; border-color: #d9e0ea; }
.controls svg { width: 18px; height: 18px; fill: currentColor; }
.time { font-size: .8rem; color: #8593ab; font-variant-numeric: tabular-nums; min-width: 38px; }
/* the scrubber is a real range input, restyled */
#seek {
flex: 1;
min-width: 0;
height: 6px;
-webkit-appearance: none;
appearance: none;
border-radius: 6px;
cursor: pointer;
/* the filled portion is a gradient driven by a custom property */
background: linear-gradient(#3b4fe4, #3b4fe4) 0 / var(--pct, 0%) 100% no-repeat, #e2e7ef;
}
#seek::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: #3b4fe4;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(22,32,46,.3);
}
#seek::-moz-range-thumb {
width: 14px; height: 14px;
border: 2px solid #fff;
border-radius: 50%;
background: #3b4fe4;
}
#seek:focus-visible { outline: 2px solid #3b4fe4; outline-offset: 3px; }
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 440px) {
.controls { flex-wrap: wrap; }
#seek { order: 3; flex-basis: 100%; margin-top: 6px; }
}const audio = document.getElementById("audio");
const toggle = document.getElementById("toggle");
const seek = document.getElementById("seek");
const now = document.getElementById("now");
const dur = document.getElementById("dur");
const rate = document.getElementById("rate");
const iPlay = toggle.querySelector(".i-play");
const iPause = toggle.querySelector(".i-pause");
const clock = (s) => {
if (!isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
return m + ":" + String(Math.floor(s % 60)).padStart(2, "0");
};
toggle.addEventListener("click", () => {
audio.paused ? audio.play() : audio.pause();
});
// drive the icon and label from the audio element's own events, not from the
// click — that way the UI stays right if playback is interrupted elsewhere
audio.addEventListener("play", () => {
iPlay.hidden = true; iPause.hidden = false;
toggle.setAttribute("aria-label", "Pause");
});
audio.addEventListener("pause", () => {
iPlay.hidden = false; iPause.hidden = true;
toggle.setAttribute("aria-label", "Play");
});
audio.addEventListener("loadedmetadata", () => {
dur.textContent = clock(audio.duration);
seek.max = audio.duration || 100;
});
audio.addEventListener("timeupdate", () => {
if (seek.matches(":active")) return; // do not fight the user mid-drag
seek.value = audio.currentTime;
paint();
now.textContent = clock(audio.currentTime);
});
seek.addEventListener("input", () => {
audio.currentTime = Number(seek.value);
now.textContent = clock(seek.value);
paint();
});
function paint() {
seek.style.setProperty("--pct", (seek.value / (seek.max || 1)) * 100 + "%");
}
const RATES = [1, 1.25, 1.5, 2, 0.75];
let r = 0;
rate.addEventListener("click", () => {
r = (r + 1) % RATES.length;
audio.playbackRate = RATES[r];
rate.textContent = RATES[r] + "\u00d7";
rate.setAttribute("aria-label", `Playback speed, ${RATES[r]} times`);
});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
Google Maps embed
A responsive map with a title, lazy loading and an optional click-to-load consent gate.
HTMLCSSJavaScriptResponsive video embed
YouTube and Vimeo that stay 16:9, with a facade that saves about a megabyte per page.
HTMLCSSJavaScriptSocial share buttons
Plain links to each network's share URL, plus the native share sheet where it exists.
HTMLCSSJavaScriptCheck 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.