# Videos a keyboard can't play, or with no captions button

Source: https://easeweb.dev/learn/video-player-controls
Topics: Images and media > Custom video players work with a keyboard and name every control (WCAG 2.1.1, 4.1.2; EN 301 549 7.1.1, 7.2.1)
The fix: Either make the play button over the poster a named native button that hands focus to the bar's play button, and show the bar from the start, with captions and audio description buttons in it whenever the video has them; or use the browser's own controls, with an audio description button right beside the player.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F59 https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.etsi.org/deliver/etsi_en/301500_301599/301549/03.02.01_60/en_301549v030201p.pdf https://www.w3.org/WAI/media/av/player/ https://www.w3.org/WAI/ARIA/apg/patterns/button/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/hidden https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/mode https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/media_controls/media_controls_impl.cc

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

The coffee roaster's brewing video has checked captions and a described version. To match the site, the shop had its own player built: the poster with a round play button in the middle, and, once the video starts, a dark bar with the progress line, play, the time, volume, and CC and AD. The round play button is a div with a click handler, and the bar stays hidden until it is clicked. With a mouse, it all works. With a keyboard, Tab goes from the heading straight to "Shop the house blend" under the video: the play button is never a stop, so the video never starts and the bar, with its captions button, never appears. A screen reader moving down the page finds nothing to press where the video is, because the play button has no role and no name.

Another common player gets the buttons right and leaves something out. Its play button and its bar are real buttons and sliders, each with a name, but the bar was drawn with play, the progress line, mute and volume only. Now the keyboard starts the video and a screen reader hears what each control is, but the captions file on the video never shows, and the described version on the server never plays, because nothing in the player turns either on.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything a mouse can do, a keyboard can do. Starting the video is done only by clicking a div, which takes no focus and listens for nothing but a click, so the keyboard can't do it, and everything the bar does waits behind that first click. Success criterion 4.1.2 Name, Role, Value asks that each control expose its name and its role. The div exposes neither: its triangle is drawn by CSS, so there is no text to read and nothing says it can be pressed. W3C's failure F59 describes this, script that makes a div a control without giving it a role, and failure F15 the wider case of custom controls that don't use the accessibility API.

The second player passes both criteria and fails two requirements EN 301 549 sets for anything that plays video with sound. Clause 7.1.1 asks for a mode of operation that displays the captions the video has, and clause 7.2.1 for a mechanism to select and play the audio description it has. The captions track is on the video and the described version is on the server, but this player can show neither. The browser's own controls would have shown the captions; the custom bar replaced them and left captions out. Neither clause is in WCAG, but Table A.1 of EN 301 549 lists both among the requirements for web pages, and meeting the clauses in that table gives a presumption of conformity with the EU's web accessibility directive.

## Who is affected

A video is often where a shop explains its product. In the first player, the recipe plays only for people who use a mouse. In the second, the captions and the described version, made for the people who need them most, are there and can't be turned on.

## What automation and AI miss

A div with a click handler has no role, so axe has nothing to check. EaseWeb's audit reports a div or span in a video's player that sets a pointer cursor of its own, has a class word such as `play`, `pause`, `mute` or `captions`, and is never reached by pressing Tab; a play layer that covers a quarter of the picture or more counts only when nothing else in the player can be reached. A control named some other way, or with no pointer cursor, is not found by this check, though one with an inline `onclick` or a button role is still reported as an unreachable control. When the play button is a real button with only an icon, axe reports the missing name as `button-name`. No check can tell whether focus moves on when the play button goes. For a player with its own controls, the audit's review note on each video says whether it found a captions button near it and, when it found none, asks a person to confirm the player can show the captions and play the described version. AI fixes can add `role="button"` and `tabindex="0"` to the div and stop there, which gives a Tab stop that Enter and Space do nothing on; or make it a real button and hide it while it has focus, so focus is left on nothing.

## Before: a play button over the poster that takes only clicks

```html
<div class="player">
  <video src="brew-guide.webm" poster="brew-guide.jpg">
    <track kind="captions" srclang="en" label="English"
      src="brew-guide.en.vtt">
  </video>
  <div class="big-play"></div>
  <div class="player-bar" hidden>
    <input type="range" class="seek" aria-label="Position"
      min="0" step="1" value="0">
    <button type="button" class="play" aria-label="Play"></button>
    <span class="time">0:00</span>
    <button type="button" class="mute" aria-label="Mute"
      aria-pressed="false"></button>
    <input type="range" class="volume" aria-label="Volume"
      min="0" max="1" step="0.1" value="1">
    <button type="button" class="captions"
      aria-pressed="false">CC<span class="visually-hidden"> captions</span></button>
    <button type="button" class="described"
      aria-pressed="false">AD<span class="visually-hidden"> audio description</span></button>
  </div>
</div>
```

```js
const player = document.querySelector('.player');
const video = player.querySelector('video');
const start = player.querySelector('.big-play');
const bar = player.querySelector('.player-bar');
const captions = player.querySelector('.captions');
const described = player.querySelector('.described');
const SOURCES = { false: 'brew-guide.webm', true: 'brew-guide.ad.webm' };

// The play button over the poster starts the video and gives way to the bar.
start.addEventListener('click', () => {
  video.play();
  start.hidden = true;
  bar.hidden = false;
});

// … the bar's play, position, mute and volume handlers

captions.addEventListener('click', () => {
  const on = captions.getAttribute('aria-pressed') !== 'true';
  video.textTracks[0].mode = on ? 'showing' : 'hidden';
  captions.setAttribute('aria-pressed', on);
});

// Swap to the described version, or back, at the same place in the video.
described.addEventListener('click', () => {
  const on = described.getAttribute('aria-pressed') !== 'true';
  const { currentTime, paused } = video;
  video.src = SOURCES[on];
  video.addEventListener('loadedmetadata', () => {
    video.currentTime = currentTime;
    if (!paused) video.play();
  }, { once: true });
  described.setAttribute('aria-pressed', on);
});
```

The play button is a div that responds only to `click`: no Tab stop, no role and no name, and its triangle is drawn by CSS, so there is nothing to read. Everything else in the player waits behind it. The bar is hidden until that first click, so a keyboard user never reaches its buttons either, however well they are built.

## Before: a bar with no captions or audio description button

```html
<div class="player">
  <video src="brew-guide.webm" poster="brew-guide.jpg">
    <track kind="captions" srclang="en" label="English"
      src="brew-guide.en.vtt">
  </video>
  <button type="button" class="big-play" aria-label="Play video"></button>
  <div class="player-bar">
    <input type="range" class="seek" aria-label="Position"
      min="0" step="1" value="0">
    <button type="button" class="play" aria-label="Play"></button>
    <span class="time">0:00</span>
    <button type="button" class="mute" aria-label="Mute"
      aria-pressed="false"></button>
    <input type="range" class="volume" aria-label="Volume"
      min="0" max="1" step="0.1" value="1">
  </div>
</div>
```

```js
const player = document.querySelector('.player');
const video = player.querySelector('video');
const start = player.querySelector('.big-play');
const bar = player.querySelector('.player-bar');

// The play button over the poster starts the video and gives way to the bar.
start.addEventListener('click', () => {
  video.play();
  start.hidden = true;
  bar.hidden = false;
  // It is gone now, so focus moves on to the bar's play button.
  bar.querySelector('.play').focus();
});

// … the bar's play, position, mute and volume handlers
```

The play button is a native button named "Play video", and the bar's controls are buttons and sliders, so the player passes 2.1.1 and 4.1.2. But it replaced the browser's controls and has nothing to turn on the captions: the `track` is on the video and its mode stays `disabled`. The described version, `brew-guide.ad.webm`, is on the server and nothing in the player plays it.

## After: a play button and a bar, with captions and audio description

```html
<div class="player">
  <video src="brew-guide.webm" poster="brew-guide.jpg">
    <track kind="captions" srclang="en" label="English"
      src="brew-guide.en.vtt">
  </video>
  <button type="button" class="big-play" aria-label="Play video"></button>
  <div class="player-bar">
    <input type="range" class="seek" aria-label="Position"
      min="0" step="1" value="0">
    <button type="button" class="play" aria-label="Play"></button>
    <span class="time">0:00</span>
    <button type="button" class="mute" aria-label="Mute"
      aria-pressed="false"></button>
    <input type="range" class="volume" aria-label="Volume"
      min="0" max="1" step="0.1" value="1">
    <button type="button" class="captions"
      aria-pressed="false">CC<span class="visually-hidden"> captions</span></button>
    <button type="button" class="described"
      aria-pressed="false">AD<span class="visually-hidden"> audio description</span></button>
  </div>
</div>
```

```js
const player = document.querySelector('.player');
const video = player.querySelector('video');
const start = player.querySelector('.big-play');
const bar = player.querySelector('.player-bar');
const captions = player.querySelector('.captions');
const described = player.querySelector('.described');
const SOURCES = { false: 'brew-guide.webm', true: 'brew-guide.ad.webm' };

// The play button over the poster starts the video and gives way to the bar.
start.addEventListener('click', () => {
  video.play();
  start.hidden = true;
  bar.hidden = false;
  // It is gone now, so focus moves on to the bar's play button.
  bar.querySelector('.play').focus();
});

// … the bar's play, position, mute and volume handlers

captions.addEventListener('click', () => {
  const on = captions.getAttribute('aria-pressed') !== 'true';
  video.textTracks[0].mode = on ? 'showing' : 'hidden';
  captions.setAttribute('aria-pressed', on);
});

// Swap to the described version, or back, at the same place in the video.
described.addEventListener('click', () => {
  const on = described.getAttribute('aria-pressed') !== 'true';
  const { currentTime, paused } = video;
  video.src = SOURCES[on];
  video.addEventListener('loadedmetadata', () => {
    video.currentTime = currentTime;
    if (!paused) video.play();
  }, { once: true });
  described.setAttribute('aria-pressed', on);
});
```

The play button over the poster is a native button, so Tab reaches it, Enter and Space press it, and a screen reader calls it "Play video, button", with no key handling to write. Pressing it hides it, so the script moves focus to the bar's play button, which says Pause once the video is playing; without that line, the browser would move focus back to the page itself, away from the player. The bar is there from the start, not only after the first press, so captions and audio description take one press, as play does, and can be on before the video starts. In the bar, the CC button shows the captions track the video already carries, and the AD button swaps in the described version at the same place, as in [videos without audio description of what they show](/learn/audio-description). Both say whether they are on with `aria-pressed`, and their names start with the letters they show.

## After: the browser's own controls, with an audio description button

```html
<div class="player">
  <video src="brew-guide.webm" poster="brew-guide.jpg" controls>
    <track kind="captions" srclang="en" label="English"
      src="brew-guide.en.vtt">
  </video>
  <button type="button" class="described"
    aria-pressed="false">Audio description</button>
</div>
```

```js
const player = document.querySelector('.player');
const video = player.querySelector('video');
const described = player.querySelector('.described');
const SOURCES = { false: 'brew-guide.webm', true: 'brew-guide.ad.webm' };

// Swap to the described version, or back, at the same place in the video.
described.addEventListener('click', () => {
  const on = described.getAttribute('aria-pressed') !== 'true';
  const { currentTime, paused } = video;
  video.src = SOURCES[on];
  video.addEventListener('loadedmetadata', () => {
    video.currentTime = currentTime;
    if (!paused) video.play();
  }, { once: true });
  described.setAttribute('aria-pressed', on);
});
```

With the `controls` attribute, the browser draws its own play button and bar, builds and names each control and makes it work with the keyboard, and offers a way to turn on the captions whenever the video has a captions track. There is no hidden bar and no focus to hand on. The site adds the one thing those controls lack, a button right under the player that swaps in the described version at the same place. What the controls look like, what they are called and which keys move them are up to each browser, so try them in every browser you support. The browser also decides where the captions control sits: Chrome keeps it in the controls' overflow menu, a level below volume, which fails Section 508 503.4.1, and EN 301 549 7.3 on a page that mainly shows the video, as [video players with no captions button in the control bar](/learn/caption-controls) explains.

## Which option to use

Both pass 2.1.1 and 4.1.2 and let viewers turn on the captions and the described version. Building the play button and the bar from native buttons and range inputs keeps the shop's own look, puts CC and AD in the bar beside volume, and costs the script above, which the browser would otherwise supply. The browser's own controls take one attribute, but they can't be made to match the site, the audio description button sits outside them, and in Chrome the captions are a menu down from volume. Use the native buttons and sliders: they are the only one of the two that puts captions beside volume in every browser, as Section 508 503.4.1 and EN 301 549 7.3 ask. The browser's controls are a quick repair for a page where neither of those applies, until the player is rebuilt.

## Implementation decisions

Name the play button for what it plays. "Play video" is enough when the page has one video; with several, each needs its own name, such as "Play: How to brew our house blend", from `aria-label` or from `aria-labelledby` pointing at the video's heading. The whole poster can respond to a click as well, as a convenience for the mouse, as long as the round button is there for the keyboard.

Keep the bar there before the video starts. A player that shows only the play button until the first press puts captions and audio description a level below play, so a viewer who needs captions has to start the video without them. EN 301 549 7.3 asks for them at the same level as the main controls on a page that mainly shows the video, as [video players with no captions button in the control bar](/learn/caption-controls) explains. If the design keeps the poster clear, put a captions button beside the play button on the poster, as one of the design ideas below does.

The bar's own controls follow the same rules as any other:

- The play button changes its name, Play or Pause, and nothing else. The ARIA Authoring Practices say a toggle button's label must not change when its state does, so a button either changes its name or keeps one name and reports `aria-pressed`, never both.
- Mute, CC and AD keep their names and report whether they are on with `aria-pressed`.
- The position slider gives the time in words through `aria-valuetext`, such as "12 seconds of 24 seconds", so a screen reader doesn't read a bare number. A progress line drawn as a div needs `role="slider"`, `tabindex="0"`, every arrow, Home and End key and all of its `aria-value` attributes in script, which `input type="range"` already does.

Keep the bar where the keyboard can see it. A bar that fades out a few seconds into the video comes back when focus moves into the player and stays while focus is in it, or a keyboard user Tabs onto controls they can't see. Each control, and the play button over the poster, shows a focus ring that stands out against the dark bar and against both the light and dark parts of the picture, as [missing focus indicators on custom buttons](/learn/visible-focus) covers.

Keyboard shortcuts such as Space or K to play, M to mute and C for captions are a welcome extra, not a replacement for the buttons. A single-letter shortcut that only works while the player has focus is allowed under 2.1.4; one that works anywhere on the page is one of the [single-key shortcuts that can't be turned off](/learn/character-key-shortcuts).

Show only what the video has: a CC button when there is a captions track, and an AD button when there is a described version. Where those buttons sit and how many presses they take is in [video players with no captions button in the control bar](/learn/caption-controls), and the captions' size and colours in [captions stuck in one style, or lost when video is converted](/learn/caption-quality). A poster that loads a YouTube or Vimeo player when it is clicked, to keep the page fast, needs the same native play button; once the embed has loaded, its controls are the platform's, so check what it really renders with the steps below.

## Verify the fix

1. Put the mouse aside. Tab to the video's play button, on the poster or in the browser's own bar, and press Enter or Space: the video starts, and focus lands on the bar's play button. Every control in the bar is a Tab stop, and the arrow keys move through the video and change the volume.
2. With a screen reader, move to the video before it starts: the play button says what it plays and that it is a button. After pressing it, the bar's play button says Pause, and mute, captions and audio description say whether they are on.
3. With the keyboard, turn on the captions and the described version, from the bar or the button beside the player. The captions appear, and the described version carries on from the same place.
4. Watch the focus ring as you Tab, on the play button over light and dark parts of the poster and on every control in the bar, before and while the video plays. The bar stays in view while focus is in it.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after updating the player or its library.

## Limits

This is a starting pattern, not a guarantee for every application. It covers how the player's controls are reached, what they say, and whether the captions and the described version can be turned on. What is in them is covered elsewhere: [videos without captions, or with unchecked automatic ones](/learn/video-captions) for the captions, and [videos without audio description of what they show](/learn/audio-description) for the description. A video that starts with its sound on needs a way to stop it first, in [autoplaying sound with no way to stop it](/learn/autoplay-audio), and one that moves by itself beside other content needs a pause, in [tickers, carousels and animations that can't be paused](/learn/moving-content). Test the complete journey in the browsers and with the assistive technology your viewers use.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
