Accessible Video Players & Captions in React
The native <video controls> element is accessible out of the box: named buttons, keyboard support, a caption menu, and a familiar interface every user already knows. Almost every custom player is a downgrade. Teams build them anyway — for branding, for analytics, for a design that matches the rest of the product — and the accessibility cost is entirely avoidable if the custom chrome is built as buttons over the native element rather than as a replacement for it.
This guide covers that wrapper, the caption tracks it exposes, and the transcript that should sit beneath it. It extends the media section of Accessible Images, Icons & Media in Component UIs.
WCAG Success Criteria Addressed:
1.2.2 Captions (Prerecorded)1.2.3 Audio Description or Media Alternative1.4.2 Audio Control2.1.1 Keyboard2.2.2 Pause, Stop, Hide
Prerequisites
- Video files with a caption track authored as WebVTT (
.vtt), plus a text transcript for anything with dialogue. - React 18+; the player is a client component because it reads and writes media state.
- A decision about whether you need custom chrome at all —
<video controls>is a legitimate and often better answer.
Start From the Native Element
Everything that makes a player accessible is already implemented in the browser. Your job is to expose it, not to rebuild it.
Implementation Guidelines:
- Render a real
<video>with<track>children. Keepcontrolsunless you are genuinely replacing every control, including the caption menu. - Never autoplay with sound.
1.4.2 Audio Controlrequires a way to stop audio that plays for more than three seconds, and autoplaying audio is blocked by most browsers anyway. - Set
preload="metadata"so duration is known without downloading the whole file, and give the element aposter. - Provide a
titleon the<video>(oraria-labelif it is the only naming route) so it is not announced as an anonymous media element.
<video
ref={videoRef}
poster="/covers/focus-management.png"
preload="metadata"
aria-label="Focus management in single-page apps, 6 minutes"
>
<source src="/media/focus.mp4" type="video/mp4" />
<track kind="captions" src="/media/focus.en.vtt" srcLang="en" label="English" default />
<track kind="descriptions" src="/media/focus.desc.vtt" srcLang="en" label="Audio descriptions" />
<p>Your browser cannot play this video. <a href="/media/focus.mp4">Download it instead.</a></p>
</video>
Controls That Report Their Own State
A custom control strip has one hard requirement: every button's accessible name and state must match what the media element is actually doing at that instant. The reliable way to achieve that is to derive the UI from media events rather than from a local boolean the click handler sets.
Implementation Guidelines:
- Subscribe to
play,pause,volumechangeandtimeupdateon the element and mirror them into state. The user can pause from the OS media keys, the Picture-in-Picture window or a notification, and your UI must follow. - For play/pause use a single button whose name changes ("Play" / "Pause"). Do not use
aria-pressedfor it — the action changes, not a toggle state. - For mute and captions,
aria-pressedis right: the action is constant and the state toggles. - Give the timeline a real
<input type="range">witharia-valuetextin minutes and seconds, since the raw number is meaningless spoken aloud.
'use client';
export function PlayerControls({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [paused, setPaused] = useState(true);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// A11y rationale: 4.1.2 Name, Role, Value — state comes from the element, so
// OS media keys and Picture-in-Picture cannot desynchronise the UI.
const sync = () => setPaused(video.paused);
video.addEventListener('play', sync);
video.addEventListener('pause', sync);
return () => {
video.removeEventListener('play', sync);
video.removeEventListener('pause', sync);
};
}, [videoRef]);
return (
<button
type="button"
onClick={() => (paused ? videoRef.current?.play() : videoRef.current?.pause())}
>
{paused ? 'Play' : 'Pause'}
</button>
);
}
Captions: the Track, Not the Pixels
Burned-in captions cannot be turned off, restyled, translated or read by a braille display. A <track> file can do all four, because the browser renders it.
Implementation Guidelines:
- Author captions as WebVTT with speaker labels and significant non-speech audio (
[door closes]), not just dialogue. - Mark exactly one track
defaultper kind, and matchsrcLangto the caption language rather than the page language. - Toggle captions by setting
track.modeto'showing'or'hidden'— never by removing the<track>element, which discards the user's position. - Keep
kind="captions"for same-language captions including sound effects, andkind="subtitles"for translations of dialogue only. The distinction is real and screen reader users rely on it.
function toggleCaptions(video: HTMLVideoElement, on: boolean) {
const track = video.textTracks[0];
if (!track) return;
// 1.2.2 Captions — the browser owns rendering, so we only change the mode.
track.mode = on ? 'showing' : 'hidden';
}
Keyboard Shortcuts Without Hijacking
Single-key shortcuts (k to play, m to mute) are convenient and dangerous: implemented globally, they fire while the user is typing in a search box, and they can collide with screen reader commands.
Implementation Guidelines:
- Scope shortcuts to the player: attach the handler to the player wrapper and only act when focus is inside it (
2.1.4 Character Key Shortcutsalso allows a global on/off setting or a modifier requirement). - Never intercept
Tab,Shift+TaborEscape, and checkevent.metaKey/ctrlKey/altKeybefore acting. - Keep
Spaceon the focused button's default behaviour instead of hijacking it for play/pause at the document level. - Document the shortcuts in visible text near the player — an undiscoverable shortcut helps nobody.
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement;
if (target.matches('input, textarea, [contenteditable]')) return;
if (event.key === 'k') { togglePlay(); event.preventDefault(); }
if (event.key === 'm') { toggleMute(); event.preventDefault(); }
}
Transcripts Earn Their Place
A transcript is the cheapest accessibility feature in this guide and the one with the broadest audience: it serves deaf and hard-of-hearing users, people in loud or quiet environments, anyone who reads faster than the presenter speaks, and search engines.
Implementation Guidelines:
- Publish the transcript as page content, not a downloadable file. Text on the page is searchable, linkable and translatable.
- Include speaker names and, for instructional content, the on-screen actions — that is where the audio description content ends up for many videos.
- Put it behind a
<details>disclosure if length is a concern, but keep it in the DOM so in-page search finds it. - Link the transcript from immediately below the player, with a name that says what it is: "Transcript for Focus management in single-page apps".
How to Verify
- Keyboard pass. Tab through the control strip. Every control must be reachable, have a visible ring, and report its state; the timeline must be operable with arrow keys.
- Screen reader pass. Confirm the play button's name flips between "Play" and "Pause", and that mute and captions announce their pressed state.
- Caption check. Play the video muted and read only the captions. Speaker changes and important sounds should be there, not just dialogue.
- External-control check. Pause from the OS media keys. If the button still says "Pause", your state is local instead of derived from the element.
- Automated scan. axe flags
video-captionfor a missing captions track andbutton-namefor unnamed icon controls — see Automated Accessibility Testing with axe-core.
Common Accessibility Mistakes
- Rebuilding the player as
<div>s. No names, no keyboard support, no state. If the chrome must be custom, build it from real buttons and a real slider. - Autoplaying with sound. Fails
1.4.2 Audio Controland startles everyone. If a video must autoplay, mute it and offer a visible pause. - Burning captions into the video. They cannot be disabled, restyled, translated or read by braille, and every typo is a re-encode.
- Keeping playback state in React alone. The OS media keys, Picture-in-Picture and the browser's own controls all change playback behind your back.
- Global single-key shortcuts.
mmutes the video while the user is typing "management" in the search box. - A timeline slider with no text value. "37" is meaningless;
aria-valuetext="0 minutes 37 seconds of 6 minutes"is not.
Conclusion
An accessible player is mostly a matter of not fighting the platform. The <video> element already implements playback, keyboard access and caption rendering, including the user's own caption preferences; a custom skin should sit on top of it as real buttons that read state from the element and write commands to it. Add WebVTT tracks rather than burned-in text, scope any shortcuts to the player, and publish a transcript on the page. What remains — writing good captions — is editorial work, and it is the part that actually determines whether the video is usable.
Frequently Asked Questions
Do I need audio description for every video?1.2.3 requires an audio description or a full media alternative for prerecorded video with sound. For screencasts and talking-head content, a thorough transcript that names on-screen actions usually satisfies it. For content where visual information carries meaning the narration does not — a demo with no commentary — you need a real description track.
Should the play button use aria-pressed?
No. Its action changes between "play" and "pause", so the accessible name should change instead. aria-pressed is for controls whose action stays constant while a state toggles — mute and captions are the right candidates in this component.
Can I rely on a third-party player library? Some are genuinely good, and using one is often better than a homegrown player. Verify before adopting: tab through the controls, check the names and states with a screen reader, and confirm the caption menu is reachable by keyboard. Treat the audit as part of the evaluation, as described in auditing a third-party React component for accessibility.
How do I handle background videos in a hero section?
Mute them, loop them, mark them decorative, and honour prefers-reduced-motion by rendering the poster image instead of the video. Anything that moves for more than five seconds also needs a pause control under 2.2.2 Pause, Stop, Hide — see Reduced Motion & Animation Accessibility.
Are auto-generated captions acceptable? Only as a first draft. Machine transcription is typically 85–95% accurate and fails hardest on names, jargon and numbers — exactly the words that carry the meaning. Budget an editing pass, and add speaker labels, which no automatic system reliably produces.
Related guides
- Accessible Images, Icons & Media in Component UIs — the parent guide and the media requirements matrix.
- Making Inline SVG Icons Accessible in React — naming the icon buttons this control strip is made of.
- Reduced Motion & Animation Accessibility — autoplay, looping and motion preferences.
- React Context for Global Accessibility Preferences — remembering a user's caption and motion choices across a session.