core accessibility principles for modern frameworks

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 Alternative
  • 1.4.2 Audio Control
  • 2.1.1 Keyboard
  • 2.2.2 Pause, Stop, Hide
A custom player is chrome over a native video element A layered diagram. At the base sits the native video element, which owns playback, the media state and the caption track rendering. Above it a control strip of real buttons — play and pause, mute, captions, and a slider for the timeline — each reading state from the media element and calling its methods. A note explains that the buttons never store playback state themselves, so the UI cannot drift from what the video is actually doing. <video> — the source of truth paused, muted, currentTime, textTracks, buffered renders WebVTT captions itself, with the user's own caption styling exposes play(), pause(), and the events the UI listens to Play / Pause name changes with state Mute aria-pressed reflects muted Captions toggles the track mode Timeline slider input type=range with a text value Controls read state from the media element and write commands to it — they never keep their own copy.

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. Keep controls unless you are genuinely replacing every control, including the caption menu.
  • Never autoplay with sound. 1.4.2 Audio Control requires 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 a poster.
  • Provide a title on the <video> (or aria-label if 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, volumechange and timeupdate on 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-pressed for it — the action changes, not a toggle state.
  • For mute and captions, aria-pressed is right: the action is constant and the state toggles.
  • Give the timeline a real <input type="range"> with aria-valuetext in 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.

WebVTT tracks compared with captions burned into the video A comparison of two approaches across four capabilities. A WebVTT track can be toggled off, restyled by the user, translated by adding another track file, and read by a braille display. Captions burned into the video pixels support none of these, and additionally cannot be corrected without re-encoding and redistributing the file. Capability WebVTT track Burned in User can turn them off yes no User can restyle size and colour yes no Extra languages cost one file yes re-encode Readable by a braille display yes no A typo in a WebVTT file is a one-line edit; a typo burned into the pixels is a re-encode and a redeploy.

Implementation Guidelines:

  • Author captions as WebVTT with speaker labels and significant non-speech audio ([door closes]), not just dialogue.
  • Mark exactly one track default per kind, and match srcLang to the caption language rather than the page language.
  • Toggle captions by setting track.mode to '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, and kind="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 Shortcuts also allows a global on/off setting or a modifier requirement).
  • Never intercept Tab, Shift+Tab or Escape, and check event.metaKey/ctrlKey/altKey before acting.
  • Keep Space on 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.
Scoping a single-key shortcut to the player The letter m is pressed in three places. With focus in a search input, the handler ignores it and the character is typed normally. With focus inside the player, the handler mutes the video. With focus elsewhere on the page, a document-level handler would still mute, which is the behaviour to avoid; a player-scoped handler does nothing. Focus in a text field "m" is typed as a letter handler returns early Focus inside the player "m" mutes the video the intended shortcut Focus elsewhere a document handler still mutes — avoid this 2.1.4 Character Key Shortcuts: single-key shortcuts must be remappable, switchable off, or active only on focus. Scoping to the player satisfies the third option and costs one focus check.
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-caption for a missing captions track and button-name for 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 Control and 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. m mutes 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.