Skip to main content

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:

  1. Use the browser's built-in controls with one attribute: controls.
  2. 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 controls attribute, 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​

index.html
<audio src="melody.wav" controls></audio>
<video src="lesson.mp4" controls width="640" height="360"></video>

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​

AttributeWhat it doesNotes
controlsShows the built-in controlsThe default choice
controlslist="nodownload"Asks the browser to hide the download buttonOnly some browsers follow it, and it does not protect the file
disablepictureinpictureHides the floating-window option on videoUse only if you have a good reason
disableremoteplaybackHides the "cast to a screen" optionSame 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​

PropertyMeaningWritable?
pausedtrue when not playingNo
currentTimeThe playing position, in secondsYes (this is seeking)
durationTotal length in seconds (NaN until known)No
volume0 (silent) to 1 (full)Yes
mutedSound off, volume keptYes
playbackRateSpeed: 1 is normal, 1.5 fasterYes
loopRepeats at the endYes
endedtrue at the endNo
readyStateHow much is loaded (0 to 4)No
bufferedWhich time ranges are downloadedNo
currentSrcThe file the browser choseNo

Methods​

MethodWhat 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​

EventWhen it fires
loadedmetadataThe duration and size are known
play and pauseThe state changed
timeupdateThe position changed (several times a second)
endedPlayback finished
volumechangeVolume or mute changed
ratechangeThe speed changed
waiting and playingBuffering started and stopped
errorSomething 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).

index.html
<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>
script.js
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)));

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: k or Space for play and pause, m for mute, f for 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: button for actions, input type="range" for sliders, select for choices. Do not build them from div elements.
  • Give every control a name: visible text, a label, or aria-label.
  • Do not remove controls unless your replacement is complete, keyboard-friendly, and tested.
  • Show a visible focus ring on every control.
  • Set aria-valuetext on 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 currentTime between 0 and duration.
  • Use textContent, not innerHTML, 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-label and aria-valuetext. Do not hard-code English strings in script.
  • Use Intl.NumberFormat for 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​

  1. Removing controls and providing no replacement, so nobody can play it.
  2. Building controls from div elements, with no keyboard or screen reader support.
  3. No accessible names on icon-only buttons.
  4. A seek bar with no aria-valuetext.
  5. Storing the state in your own variable and falling out of step with the element.
  6. Not handling the rejected promise from play().
  7. Reading the duration before it is known, and getting NaN.
  8. Announcing every timeupdate to screen readers.
  9. Hiding the controls with no way to bring them back by keyboard.
  10. Tiny buttons that are hard to tap.
  11. Taking over Space and the arrow keys for the whole page.
  12. 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:

  1. Add controls to an <audio>, and use it with the keyboard only.
  2. Remove controls, and see that nothing is shown.
  3. Build a Play and Pause button that uses play() and pause().
  4. Keep the button's label in step with the play and pause events.
  5. Build a seek bar with input type="range", driven by timeupdate.
  6. Add aria-valuetext to it, and test it with a screen reader.
  7. Add volume, mute, and speed controls.
  8. Add Back 5 s and Forward 5 s buttons that never go out of range.
  9. Show a "Buffering…" message with waiting and playing.
  10. Bonus: add a controls attribute 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 controls by 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 as timeupdate.
  • 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-valuetext to 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.