A Custom Audio Player Built From Scratch
The native <audio> element works everywhere, but it doesn’t offer much to look at. If audio is a core feature of your site — for example, a podcast homepage — you might want something that fits your design better. The good news is that you can build a fully functional player from standard HTML elements, a little CSS, and the browser’s built-in media APIs.
Rather than replicating the default control bar, we can create our own play/pause button, seek slider, time readouts, volume control, and mute toggle. The browser provides everything we need through the HTMLMediaElement interface, which the HTMLAudioElement interface inherits.
The Pieces of a Player
Before touching a line of JavaScript, it helps to list the interactive parts you need. A standard player has at least these six controls:
- a play/pause button
- a seek slider
- a current time readout
- a total duration readout
- a mute button
- a volume slider
Each one maps to a semantic element. Play/pause and mute are <button> elements. Current time and duration are <span> elements; the span for current time should start at 0:00, and the duration span displays the audio length in mm:ss format. Seek and volume are both <input type="range">. Volume percentage belongs in an <output> element, since its value changes entirely based on user input.
For the play and pause icons, you can swap between two static states, or you can animate the transition with a library like Lottie. Using Lottie, you load an animation into the button with loadAnimation(). The icon starts as a play symbol; when you click, the animation morphs into a pause symbol, and a state variable tells the script which action to perform next.
Styling range inputs is more work than most native controls. WebKit browsers lack a dedicated pseudo-element for the filled portion of the track, so you need a ::before pseudo-element on the input itself. Firefox provides ::-moz-range-progress for this, and older IE offers ::-ms-fill-lower. You also have to consider vendor prefixes on the track and thumb selectors. Because the amount filled is dynamic, you can update a CSS custom property — for instance, --before-width — inside the input event handler. The seek slider can additionally show the buffered portion by layering a linear-gradient() on the track, where a translucent stop marks buffered bytes and a stronger color marks the portion you can scrub toward.
Getting the Duration Right
The audio element on the page should include a preload attribute. Setting it to metadata means the browser loads only the file length and similar information up front, which is enough to populate the duration readout without pulling down the full sound file.
This is not a guarantee. Browsers treat preload as a hint, not a directive. Safari on cellular networks, for example, may skip loading entirely until the user initiates playback.
The duration property returns the length in seconds, usually in decimal values. To display it as a mm:ss readout, convert with Math.floor(). But you should also pad the seconds part so that 4 minutes and 8 seconds shows as 4:08, not 4:8.
The loadedmetadata event fires when the length is available, but it can fire before your listener attached. A more reliable approach is to check the audio element’s readyState property first. Its values map to readiness levels described by the MDN Web Docs:
0– no media data at all1– metadata is available2– enough data for one frame3– data for a few frames from the current position4– enough data for uninterrupted playback
If readyState is at least 1, render the duration immediately. Otherwise, attach the loadedmetadata listener as a fallback.
Seeking, Buffering, and Time Updates
A range slider measures the current position. But a slider whose max value stays at the default of 100 will reach the end before the audio does. Set the seek slider’s max attribute to the audio duration in seconds once that duration is known, so the slider’s mathematical range lines up exactly with the audio timeline.
The volume slider poses no such problem. Its max stays at 100, which maps nicely to the volume property on HTMLMediaElement. That property accepts a value between 0 and 1, so divide the slider value by 100 and assign the result.
When the user drags the seek slider, you want to reflect their new position in the current time readout. Listen for the input event rather than change. The input event fires continuously as the thumb moves — a change of value from 1 to 20 fires it at every integer point. The change event fires only when the user releases the thumb, so intermediate times would not show. When the slider is moved to seek through the file, however, you also need to update the audio element’s currentTime property. For that assignment, use the change event so small slider adjustments do not make audio snippets play at artificially fast speeds.
That said, the audio’s own playback should also move the slider thumb. The timeupdate event fires roughly four times per second while the currentTime changes. But there is a catch: if your script begins updating the slider programmatically the moment it loads, the user will not be able to grab and drag the thumb while the audio is playing. The handler would keep overriding their input.
One way to handle this is with requestAnimationFrame(), which gives you the loop of slider updates in a callable function. Create a function like whilePlaying() that calls the slider update, then request another animation frame as long as you want it to run. Store the request ID so you can cancel it on pause. On the slider’s input event, cancel the loop so the user is in control. In the change handler, resume the loop — but only if the button state still indicates that audio is playing.
The buffered amount display works similarly. The buffered property on a media element returns a TimeRanges object containing one or many non-overlapping spans of already-loaded audio. The last range in that object is normally the one closest to the current playback position. You can seed a variable with the end time of that last range to show the bit the user can play without waiting. This calculation should run inside a progress event handler — that event fires as the media loads — and also when loaded metadata is first available. Then convert the buffered time into a percentage of duration and roll that into the --buffered-width custom property from your CSS.
Be careful with the seekable property here. It sounds similar, but it reports the ranges the browser can seek to, regardless of whether those parts have been downloaded. On large files with byte-range requests enabled, that could report a range that extends the full duration even when most of the audio is missing. Use buffered for the visual indicator.
Putting Audio Controls in Hand
The play/pause button handler already toggles a Lottie animation and a state variable. Add to the same handler:
Call audio.play() when the action is play, and audio.pause() when paused. For the mute button, the toggle is on the muted boolean property of the media element.
One subtlety: the audio element and the icon control are separate components. Playback and volume state do not automatically update the UI. If you pause the audio programmatically from the Media Session API (more on that below), the pause icon would stay in play mode. Likewise, the volume slider would not move if volume is changed elsewhere. In a real deployment you would want event listeners that monitor the media element’s state and sync the controls.
Reaching Outside the Browser Tab
The Media Session API extends your control beyond the page. It lets a user pause, play, or skip from places like the OS notification center, a media hub, a smartwatch, or through a voice assistant. When it is active, actions initiated from those control surfaces interface with your audio element as if the user clicked the button on the page.
One Module to Rule Them All
All this code — audio element, animated icons, multiple input handlers, and buffering progress — belongs inside a web component for cleaner separation of concerns. A custom element gives you an encapsulated player which you can drop into a page without leaking global state or script.
The possibilities here go far beyond the shared helpers. HTMLMediaElement exposes many more properties and methods, and the Media Session API makes the player feel native no matter where the user controls playback. Building a custom player is a lesson in specificity, too: even an understated control bar requires thinking through states, loop cancellation, and readiness checks to work correctly.



