Custom Video Player Controls: Why Replacing Native Video Controls Breaks Keyboard and Screen Reader Access
Quietramp ·
Custom Video Player Controls: Why Replacing Native Video Controls Breaks Keyboard and Screen Reader Access
A hero video on a homepage, a product-demo clip on a PDP, a size-guide walkthrough — on most e-commerce sites, these don’t use the browser’s plain gray video bar. Design teams routinely hide it and build a custom “skin” instead: brand-colored play button, a thin progress bar that matches the site’s accent color, icon-only volume and fullscreen controls. Visually, it’s an improvement. Functionally, it’s a rebuild — and it’s easy to rebuild only the parts a mouse user needs.
Quick answer: the native <video controls> attribute gives a browser’s own play/pause, seek, volume, and fullscreen controls full keyboard and screen-reader support for free. The moment a site hides that attribute and substitutes custom HTML, all of that support has to be rebuilt by hand — under WCAG SC 2.1.1 Keyboard (Level A) for operability and SC 4.1.2 Name, Role, Value (Level A) for what assistive technology can detect. The three places this most often goes wrong: icon-only buttons with no accessible name, a seek or volume bar built as a mouse-drag-only element with no keyboard support, and a play/pause icon that changes appearance without exposing its new state to a screen reader.
This article covers technical accessibility practice, not legal advice — it explains what WCAG 2.1 AA requires for custom media controls and how to meet it, not whether a specific implementation carries legal risk.
What the native controls attribute gives up for free
Per MDN’s <video> element reference, the controls attribute means “the browser will offer controls to allow the user to control video playback, including volume, seeking, and pause/resume playback.” Every major browser’s built-in control set already ships with full keyboard support (Tab to reach each control, Space/Enter to activate buttons, arrow keys to move the seek and volume sliders) and correct accessible names and roles exposed to assistive technology — because it’s built from the browser’s own native widget toolkit, not custom markup.
Removing controls and building a replacement skin doesn’t remove that requirement — it just moves the work from “already done by the browser” to “your team’s responsibility.” Nothing about that trade is wrong on its own; branded video controls are a completely reasonable design choice. The failure mode is treating the redesign as purely visual and never rebuilding the keyboard and assistive-technology behavior the native version had.
Failure 1: icon-only buttons with no accessible name
A custom play/pause, mute, or fullscreen button is frequently a bare icon — an inline SVG, an icon-font glyph, or a background-image — inside a <button> or, worse, a <div> or <span> with a click handler and no button semantics at all. Visually it’s obvious what it does. To a screen reader, a <button> with only an icon inside and no text has no accessible name at all, and a <div>/<span> with a click handler has no name, role, or keyboard support of any kind.
SC 4.1.2 Name, Role, Value requires that “for all user interface components (including but not limited to: form elements, links and components generated by scripts), the name and role can be programmatically determined; states, properties, and values that can be set by the user can be programmatically set; and notification of changes to these items is available to user agents, including assistive technologies.” An icon-only button fails the first clause outright if nothing supplies an accessible name.
The fix: use a real <button> element (which is focusable and keyboard-activatable by default) and give it an aria-label:
<button type="button" class="play-pause-btn" aria-label="Play video">
<svg aria-hidden="true" focusable="false"><!-- play icon --></svg>
</button>
aria-hidden="true" on the icon keeps it from being announced as an unlabeled image; the button’s own aria-label supplies the name a screen reader announces instead.
Failure 2: a seek or volume bar with no keyboard support
The progress bar and volume control are usually the two custom widgets furthest from anything HTML gives you natively — most implementations are a positioned <div> with a draggable “thumb” element, styled to look like a slider, operated entirely with mousedown/mousemove/touchmove listeners. Nothing about that markup is focusable, and nothing responds to a key press.
SC 2.1.1 Keyboard requires that “all functionality of the content is operable through a keyboard interface without requiring specific timings for individual keystrokes, except where the underlying function requires input that depends on the path of the user’s movement and not just the endpoints.” Moving a seek position or a volume level is a target-value action, not a path-dependent one (the result of dragging to a point matters, not the drag path itself) — so this exception doesn’t apply, and a keyboard-only user needs a way to do the same thing arrow keys already do for a native slider.
The fix is the WAI-ARIA APG Slider Pattern, which W3C’s own Media Seek Slider Example applies directly to this component: it “illustrates a seek control that could be used to move the current play position in an audio or video media player.” The pattern’s roles and keyboard model:
role="slider"on the focusable thumb element, witharia-valuenow,aria-valuemin, andaria-valuemaxreflecting the current position and range.- Right Arrow/Up Arrow increases the value one step; Left Arrow/Down Arrow decreases it; Home jumps to the minimum; End jumps to the maximum.
aria-valuetextfor a human-readable announcement, since a raw number is a poor substitute for a real one. The seek example’s own worked case: if the play position is 4 minutes 3 seconds into a video, the underlying slider value is 243 (total seconds). Withoutaria-valuetext, “assistive technology users would be told that the position is 243, which is significantly more difficult to comprehend than 4 minutes, 3 seconds” — so the implementation converts the raw seconds value into that readable string and exposes it viaaria-valuetextinstead.
<div class="seek-bar"
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin="0"
aria-valuemax="243"
aria-valuenow="90"
aria-valuetext="1 minute, 30 seconds of 4 minutes, 3 seconds">
<div class="seek-bar-fill" style="width: 37%"></div>
</div>
seekBar.addEventListener('keydown', (e) => {
const step = 5; // seconds
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { video.currentTime += step; updateSlider(); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { video.currentTime -= step; updateSlider(); }
if (e.key === 'Home') { video.currentTime = 0; updateSlider(); }
if (e.key === 'End') { video.currentTime = video.duration; updateSlider(); }
});
This snippet is illustrative — a production implementation also needs drag/touch handling kept in sync with the keyboard path and rounding logic for aria-valuetext, both omitted here for brevity. The volume control follows the identical pattern with a 0–100 (or 0–1) range instead of a time range.
Failure 3: a state change nothing announces
Clicking a custom play/pause button usually swaps an icon — a play triangle becomes a pause bar — and nothing else. Visually the state is obvious. A screen reader user who just activated that button gets no confirmation of what happened unless the accessible name or a state is updated alongside the icon; landing back on a button whose announced name never changed reads as if the click did nothing.
The fix is one line: update the button’s aria-label to reflect the new state every time playback toggles, the same way its icon already does.
function togglePlay() {
if (video.paused) {
video.play();
playBtn.setAttribute('aria-label', 'Pause video');
} else {
video.pause();
playBtn.setAttribute('aria-label', 'Play video');
}
}
Why an automated scan won’t catch most of this
axe-core splits this accessible-name check across two rules, each scoped to a different element type: button-name (tagged wcag412) checks real <button> elements, while aria-command-name (also wcag412) checks elements carrying role="button", role="link", or role="menuitem". Both are genuine, useful checks — a plain icon-only <button> with no aria-label fails button-name correctly, and a <div role="button"> with no name fails aria-command-name correctly. But a custom seek bar or volume control built as a bare <div> with a click handler and no role attribute at all isn’t a false pass on either rule — it’s outside what either one evaluates, because both target a specific element or role, and axe has no way to know an unlabeled, role-less <div> was meant to be an interactive control in the first place. Whether arrow keys actually move the seek position, and whether a state change gets announced, both require someone operating the widget with a keyboard and a screen reader, not a markup scan. Our guide to what automated scanners typically miss covers this gap more generally; a custom video player is one of its more common real-world instances.
FAQ
Does this mean we shouldn’t build custom video player skins?
No. WCAG doesn’t require using the browser’s default <video controls> bar — it requires that whatever replaces it be operable by keyboard and correctly exposed to assistive technology. A branded skin that does both is fully compliant.
Do captions and audio description matter here too? Yes, but they’re a separate requirement from control operability. Our guide to making product videos accessible covers captions, transcripts, audio description, and autoplay-audio muting in depth — this article stays scoped to the play/pause/seek/volume/fullscreen control set itself.
Is a third-party video player embed (YouTube, Vimeo) exempt from this? No, but the responsibility shifts. The same SC 2.1.1 and 4.1.2 requirements apply to whatever your visitor actually interacts with — if you embed a vendor’s default player unmodified, you’re relying on that vendor’s own accessibility work; if you build a custom skin on top of their API, the custom parts are yours to get right.
A full audit checks what a scan can’t
Video player controls are one interactive pattern among many that a purely visual QA pass won’t catch — a full Quietramp audit checks your whole site against WCAG 2.1 AA, verified by a person operating it with a keyboard and screen reader, not just an automated scan. See a real sample report of what that looks like, or check pricing — $890 one-time, $99/month if you want ongoing re-checks after fixes ship.
This is educational content about technical accessibility standards, not legal advice. Meeting WCAG 2.1 AA does not guarantee legal compliance with the ADA or any other law, and nothing here should be read as a compliance guarantee. Consult qualified counsel for legal risk questions.
This article was drafted with AI assistance and reviewed by a person for accuracy before publication.