HTML Media Controls
Every media player needs a way to start, stop, skip, and turn the volume up and down. HTML gives you two choices:
- Use the browser's built-in controls with one attribute:
controls. - Build your own with buttons and sliders, using the media API.
This lesson covers both, and shows how to make a custom player that works for everyone, including people who use only a keyboard or a screen reader.
What You Will Learn
- The
controlsattribute, and what it gives you - When a custom player is worth building
- The media API: properties, methods, and events
- Building play, seek, volume, mute, and speed controls
- Accessibility rules for custom controls
- Common mistakes
The Built-in Controls
- HTML Code
- Browser Output
<audio src="melody.wav" controls></audio>
<video src="lesson.mp4" controls width="640" height="360"></video>
Preparing the sound…
<audio src="melody.wav" controls></audio>controlslist is a hint that only some browsers follow, and it does not stop anyone from getting the file.
Adding controls gives you, with no code, things that take real work to build yourself:
- Play and pause, a seek bar, a time display, volume, and mute.
- On video: full screen, and often picture-in-picture, captions, and a speed menu.
- Keyboard and screen reader support, tested by the browser makers.
- A look that matches the visitor's device.
The look differs in every browser. You cannot restyle it with CSS beyond a few things, such as size, borders, and colour scheme. That is fine for most sites.
Without controls, the media element is invisible (audio) or a silent, unclickable picture (video), unless you provide your own controls.
Other Attributes That Affect Controls
| Attribute | What it does | Notes |
|---|---|---|
controls | Shows the built-in controls | The default choice |
controlslist="nodownload" | Asks the browser to hide the download button | Only some browsers follow it, and it does not protect the file |
disablepictureinpicture | Hides the floating-window option on video | Use only if you have a good reason |
disableremoteplayback | Hides the "cast to a screen" option | Same advice |
None of these protects your media. A visitor can still find the file in the network panel.
Why Build Your Own?
Sometimes the built-in player is not enough:
- You want the same look in every browser.
- You need extra features: chapters, a playlist, jump-back buttons, or a transcript that follows the sound.
- You want to start and stop the media from other buttons on the page.
- You are building a game or interactive lesson where media is part of the design.
But a custom player is extra work to make accessible. Before you build one, ask: "Does this give my visitors something the built-in controls cannot?" If not, keep controls.
The Media API
Both <audio> and <video> share one set of properties, methods, and events (called HTMLMediaElement).
Properties
| Property | Meaning | Writable? |
|---|---|---|
paused | true when not playing | No |
currentTime | The playing position, in seconds | Yes (this is seeking) |
duration | Total length in seconds (NaN until known) | No |
volume | 0 (silent) to 1 (full) | Yes |
muted | Sound off, volume kept | Yes |
playbackRate | Speed: 1 is normal, 1.5 faster | Yes |
loop | Repeats at the end | Yes |
ended | true at the end | No |
readyState | How much is loaded (0 to 4) | No |
buffered | Which time ranges are downloaded | No |
currentSrc | The file the browser chose | No |
Methods
| Method | What it does |
|---|---|
play() | Starts. Returns a promise that can be rejected (blocked) |
pause() | Pauses |
load() | Starts loading again, after changing the source |
canPlayType(type) | Asks whether a type can be played |
Events
| Event | When it fires |
|---|---|
loadedmetadata | The duration and size are known |
play and pause | The state changed |
timeupdate | The position changed (several times a second) |
ended | Playback finished |
volumechange | Volume or mute changed |
ratechange | The speed changed |
waiting and playing | Buffering started and stopped |
error | Something failed |
Try It: A Custom Player
This player uses a real <audio> element with no controls, and builds everything else with ordinary form controls. Play it, drag the seek bar, change the speed, and then try it with only the keyboard (Tab, Space, and the arrow keys).
- HTML Code
- Browser Output
<audio id="sound" src="melody.wav" preload="metadata"></audio>
<div role="group" aria-label="Audio player">
<button type="button" id="back" aria-label="Back 5 seconds">−5 s</button>
<button type="button" id="play">Play</button>
<button type="button" id="forward" aria-label="Forward 5 seconds">+5 s</button>
<span id="time" aria-hidden="true">0:00 / 0:00</span>
<label for="seek">Seek</label>
<input type="range" id="seek" min="0" max="0" step="0.1" value="0">
<label>Volume <input type="range" id="vol" min="0" max="1" step="0.05" value="1"></label>
<label><input type="checkbox" id="mute"> Mute</label>
<label>Speed
<select id="speed">
<option value="0.75">0.75×</option>
<option value="1" selected>1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
</select>
</label>
</div>
const sound = document.getElementById("sound");
const play = document.getElementById("play");
const seek = document.getElementById("seek");
const time = document.getElementById("time");
const clock = (s) =>
Math.floor(s / 60) + ":" + String(Math.floor(s % 60)).padStart(2, "0");
sound.addEventListener("loadedmetadata", () => {
seek.max = sound.duration;
});
sound.addEventListener("timeupdate", () => {
seek.value = sound.currentTime;
seek.setAttribute("aria-valuetext", clock(sound.currentTime) + " of " + clock(sound.duration));
time.textContent = clock(sound.currentTime) + " / " + clock(sound.duration);
});
play.addEventListener("click", () => {
if (sound.paused) {
sound.play().catch(() => {});
} else {
sound.pause();
}
});
sound.addEventListener("play", () => (play.textContent = "Pause"));
sound.addEventListener("pause", () => (play.textContent = "Play"));
seek.addEventListener("input", () => (sound.currentTime = Number(seek.value)));
back.onclick = () => (sound.currentTime = Math.max(0, sound.currentTime - 5));
forward.onclick = () => (sound.currentTime = Math.min(sound.duration, sound.currentTime + 5));
vol.addEventListener("input", () => (sound.volume = Number(vol.value)));
mute.addEventListener("change", () => (sound.muted = mute.checked));
speed.addEventListener("change", () => (sound.playbackRate = Number(speed.value)));
Every control here is a real button, input or select, so Tab, Space and the arrow keys already work. The sound is generated by the page, so no file is needed.
A few design choices in this player are worth copying:
- The state comes from the media element, not from a separate variable. Every control reads from
sound.paused,sound.currentTime, and so on, through events. So if the visitor uses a keyboard media key, a headset button, or another tab, the player still stays correct. - The Play button's label changes between "Play" and "Pause", because the action changes. This is different from a disclosure widget, where the label should stay put and the state is announced.
- The seek bar has
aria-valuetext, so a screen reader says "0:03 of 0:08" instead of "3.1". - The time text is hidden from screen readers (
aria-hidden), because it changes several times a second and would be read out over and over. The seek bar already carries the information. - Buttons are large, with a comfortable tap target.
Progressive Enhancement
A smart way to build it: ship the native controls in the HTML, and remove them with script only when your custom player is ready.
<video src="lesson.mp4" controls width="640" height="360"></video>
const video = document.querySelector("video");
video.controls = false; // hide the native controls
buildMyPlayer(video); // then add your own
If your script fails to load, the visitor still has working controls.
Showing Progress and Buffering
sound.addEventListener("progress", () => {
if (sound.buffered.length > 0) {
const end = sound.buffered.end(sound.buffered.length - 1);
console.log("Downloaded up to " + end.toFixed(1) + " seconds");
}
});
sound.addEventListener("waiting", () => console.log("Buffering…"));
sound.addEventListener("playing", () => console.log("Playing again"));
Show a text message such as "Buffering…" in a role="status" element, so people who cannot see a spinner know what is happening.
Keyboard Shortcuts
The built-in player already supports the keyboard. In a custom player, native controls give you Tab, Space, Enter, and the arrow keys on sliders for free. Extra shortcuts are optional, but follow these rules:
- Do not take over Space or the arrow keys for the whole page. Handle keys only when the player has focus.
- Use common keys:
kor Space for play and pause,mfor mute,ffor full screen, left and right arrows for seeking. - Tell visitors the shortcuts exist, in text near the player.
- Do not make a shortcut the only way to do something.
player.addEventListener("keydown", (event) => {
if (event.target.matches("input, select, textarea")) return; // leave form fields alone
if (event.key.toLowerCase() === "m") {
sound.muted = !sound.muted;
}
});
Accessibility at a Glance
- Use real elements:
buttonfor actions,input type="range"for sliders,selectfor choices. Do not build them fromdivelements. - Give every control a name: visible text, a
label, oraria-label. - Do not remove
controlsunless your replacement is complete, keyboard-friendly, and tested. - Show a visible focus ring on every control.
- Set
aria-valuetexton sliders so values make sense ("0:03 of 0:08", "50 percent"). - Make targets large: at least 24 by 24 CSS pixels, and ideally 44.
- Keep enough contrast on controls drawn over video (4.5 to 1 for text, 3 to 1 for icons and borders).
- Announce changes sparingly. Use one
role="status"message for buffering or errors, not for every second of time. - Do not hide controls forever. If they fade away while playing, they must return on focus and on keyboard use.
- Provide a way to pause, stop, or hide anything that moves or sounds for more than 5 seconds.
Security and Privacy Notes
- Controls do not protect media. Hiding the download button, or the player, does not stop anyone fetching the file. Serve private media only to authorised visitors, on the server.
- Do not trust values from the page (for example a time passed in a URL) without checking that they are numbers within range. Clamp
currentTimebetween 0 andduration. - Use
textContent, notinnerHTML, when showing titles or lyrics from user data. - Do not track more than you need. Sending an analytics event every second of playback can reveal what people watch. Collect only what you need, and explain it in your privacy notice.
- Do not auto-send playback data to third parties without consent where the law requires it.
Global Audience Notes
- Translate all control labels, including
aria-labelandaria-valuetext. Do not hard-code English strings in script. - Use
Intl.NumberFormatfor percent and speed values, so numbers appear in the visitor's style. - Right-to-left pages: the time order in a seek bar usually still runs left to right, and many players keep it that way. Mirror the buttons (use logical CSS properties), but test with native speakers.
- Keep layouts flexible, since translated labels can be much longer.
- Do not depend on icons alone. A play triangle is familiar to many people, but a text label or tooltip helps everyone.
- Test on touch screens and slow connections. Large targets and clear "Buffering…" messages help a lot.
Common Mistakes to Avoid
- Removing
controlsand providing no replacement, so nobody can play it. - Building controls from
divelements, with no keyboard or screen reader support. - No accessible names on icon-only buttons.
- A seek bar with no
aria-valuetext. - Storing the state in your own variable and falling out of step with the element.
- Not handling the rejected promise from
play(). - Reading the
durationbefore it is known, and gettingNaN. - Announcing every
timeupdateto screen readers. - Hiding the controls with no way to bring them back by keyboard.
- Tiny buttons that are hard to tap.
- Taking over Space and the arrow keys for the whole page.
- Treating
controlslist="nodownload"as protection.
Practice Time
Try these in your editor, with a short audio or video file you have the right to use:
- Add
controlsto an<audio>, and use it with the keyboard only. - Remove
controls, and see that nothing is shown. - Build a Play and Pause button that uses
play()andpause(). - Keep the button's label in step with the
playandpauseevents. - Build a seek bar with
input type="range", driven bytimeupdate. - Add
aria-valuetextto it, and test it with a screen reader. - Add volume, mute, and speed controls.
- Add Back 5 s and Forward 5 s buttons that never go out of range.
- Show a "Buffering…" message with
waitingandplaying. - Bonus: add a
controlsattribute in the HTML, and remove it in script, so the page still works if the script fails.
Quick Recap
Click a question to check your answer.
1. What does the controls attribute do?
It shows the browser's built-in player, with play, pause, seek, volume, and (on video) full screen. It includes keyboard and screen reader support, and looks different in each browser.
2. When should you build a custom player?
Only when it gives visitors something the built-in controls cannot: a consistent design, extra features, or interaction with other parts of the page. Otherwise, keep controls.
3. Which properties do you change to seek, set volume, and change speed?
currentTime, volume (0 to 1), and playbackRate. Use muted to mute without changing the volume.
4. Why should a custom player read its state from the media element?
Media can be controlled from outside your buttons (keyboard media keys, headsets, other tabs). If the controls listen to events like play, pause, and timeupdate, they always show the true state.
5. How do you make a custom slider accessible?
Use input type="range" with a label, and set aria-valuetext so assistive technology says something meaningful, such as "0:03 of 0:08".
6. Does hiding the controls protect the file?
No. The file can still be found and downloaded. Protect private media on the server.
Key Takeaways
- Use
controlsby default: it is free, accessible, and familiar. - Build a custom player only when it adds real value, and make it keyboard and screen reader friendly from the start.
- The media API gives you
play(),pause(),currentTime,volume,muted,playbackRate, and events such astimeupdate. - Let events drive the interface, so the player is never out of step with the media.
- Use real form controls, give them names, and add
aria-valuetextto sliders. - Handle the rejected promise from
play(), and announce buffering in text. - Controls do not protect files, so keep private media on the server.
- Translate every label, and plan for touch screens, slow connections, and right-to-left pages.
Next, add text to your media with Track, or return to the Audio and Video overview.