Video
Player controls, poster frames, captions, and the autoplay rules that keep it legal and quiet.
Also called Player, Media Player — in this system all of them are Video.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The poster holds the frame
A poster at the same aspect ratio means nothing shifts when the video loads — and it gives the user something to decide about before any bytes are spent.
Captions and transcript
Captions are required at level A. A transcript beside the player is not required and is often more useful — it is searchable, skimmable and copyable.
0:12 The health check failed in eu-west-2 after the connection pool saturated. 0:24 We rolled back to build 4019, and the rollback completed in about eight seconds.
Autoplay rules
Muted or it will not play at all. Looping decorative clips need a pause control under WCAG 2.2.2, and reduced-motion users should get the poster instead.
The control bar
Play, mute, elapsed time, captions, settings and full screen. The scrubber is a real range input, so keyboard and screen-reader support come for free.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A 16:9 frame with a poster, a scrim-backed control bar, captions positioned above the controls, and a caption line linking to the transcript.
- Aspect ratio16:9, reserved
Held before anything loads, exactly as for an image. A player that resizes on load shifts everything below it.
- PosterSame ratio, real frame
A frame from the video rather than a generic thumbnail. It is what the user decides on before any bytes are spent.
- Play affordance56px, centred
Large and unmissable. It is the only control that matters before playback starts, and it must be a real button.
- Control bar40px over a scrim
The gradient is not decoration — white controls over an arbitrary frame have no contrast without it.
- ScrubberReal input[type=range]
Native, so arrow keys, Home, End and screen-reader value announcements all work without being rebuilt.
- Caption positionAbove the controls
Captions hidden behind the control bar is the most common video bug, and it appears the moment the controls fade in.
- Transcript linkBelow the player
Not required by WCAG, and frequently more useful than the video: searchable, skimmable, copyable and printable.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| black | — | The frame behind the video, so letterboxing is deliberate |
| white | — | Controls, which sit over an unpredictable image |
| --ds-focus-ring | — | Focus outline on controls |
| --ds-fg-muted | — | The caption line and duration beneath the player |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-2 | Control bar padding |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Player corners |
Typography
| Token | Value | Used for |
|---|---|---|
| tabular-nums | — | Elapsed time, so it does not jitter every second |
Motion
| Token | Value | Used for |
|---|---|---|
| controls fade | Auto-hide during playback |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Inline | 16:9 | — | — | — | 40rem | — | Inside prose, matched to the reading measure. |
| Full-width | 16:9 | — | — | 100% | — | — | A demo that is the subject of the page. |
| Thumbnail | 16:9 | — | — | 160px | — | — | In a list or a grid. Poster plus a duration badge, no controls. |
| Control bar | 40px | 0 8px | — | — | — | 44px on coarse pointers | Over a gradient scrim, since white on an arbitrary frame has no contrast. |
| Play affordance | 56px | — | — | — | — | — | Centred, unmissable, and a real button. |
| Captions | — | — | 13–16px | — | — | — | User-resizable. Positioned above the control bar, never behind it. |
<track kind="captions" srclang="en"
label="English" src="/v.vtt" default />@media (prefers-reduced-motion: reduce) {
video { display: none } .poster { display: block }
}<video autoplay> → blocked, shows a frozen frameNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Controls sit over an unpredictable frame, so they need a scrim. White icons over an arbitrary video have no assertable contrast.
- Caption text needs a solid or near-solid background, not a text shadow. A shadow fails against a bright frame.
- Captions must be resizable to at least 200% without being clipped — the user’s setting, not yours.
- The focus ring on a control must be visible against both the scrim and a bright frame, which usually means an inset ring rather than an outset one.
Keyboard
| Space / K | Play and pause. Both, because both are conventions users arrive with. |
| ← / → | Seek by five seconds. ↑ / ↓ adjusts volume. |
| Home / End | Jump to the start or end. |
| M | Toggle mute. C toggles captions. F toggles full screen. |
| Tab | Moves through the control bar, which must not auto-hide while it holds focus. |
Screen readers
- The player announces its length up front: "Rolling back a failed deployment, video, 3 minutes 34 seconds".
- The scrubber needs aria-valuetext in real units. A value between 0 and 1 conveys nothing about position.
- Do not announce elapsed time continuously. It is the most common way a custom player becomes unusable with a screen reader.
Focus & touch
- The control bar must not auto-hide while any control inside it has focus — otherwise a keyboard user loses the thing they are operating. Entering full screen keeps focus inside the player; leaving it returns focus to the control that triggered it.
- playsInline is mandatory, or iOS takes every video full screen on play. Controls need 44px targets, which usually means a 52px bar. Tap-to-play-pause on the frame is expected on mobile, but keep the explicit button too — a tap-only player is undiscoverable for anyone who does not already know the convention.
| Attribute | Applied to | Notes |
|---|---|---|
| <track kind="captions"> | The video | Required at level A. Auto-generated captions are a starting point, not a delivery. |
| <track kind="descriptions"> | The video | Audio description for anything conveyed visually and not spoken — required at AA. |
| aria-label | Every control | "Play", "Mute", "Show captions". Icon-only controls over video are the least self-explanatory in any product. |
| aria-valuetext | The scrubber | "1 minute 12 seconds of 3 minutes 34" — a bare 0–1 range value is meaningless. |
| aria-live="off" | The elapsed time | Explicitly off. A time display that announces every second is unusable. |
Example usage
1// Prefer the native player: keyboard control, captions, picture-in-picture,2// AirPlay, playback speed and platform accessibility all come free.3<video4 controls5 playsInline // or iOS takes it full screen on play6 preload="metadata" // enough for the duration and the poster7 poster="/rollback-poster.jpg" // holds the ratio, costs a fraction8 width={1600}9 height={900}10>11 <source src="/rollback.webm" type="video/webm" />12 <source src="/rollback.mp4" type="video/mp4" />1314 {/* Required at level A. Auto-generated is a start, not a delivery. */}15 <track kind="captions" srcLang="en" label="English" src="/rollback.vtt" default />16 <track kind="descriptions" srcLang="en" label="Audio description" src="/rollback-ad.vtt" />1718 <p>Your browser cannot play this video. <a href="/rollback.mp4">Download it</a>.</p>19</video>2021// The only autoplay that works. Unmuted autoplay is blocked everywhere, so22// the player looks broken rather than loud.23<video autoPlay muted loop playsInline poster="/poster.jpg" />2425// An autoplaying loop can trigger vestibular symptoms.26const reduced = useMediaQuery('(prefers-reduced-motion: reduce)')27{reduced ? <img src="/poster.jpg" alt={description} /> : <video autoPlay muted loop />}2829// If you must build a custom player, the scrubber stays a real range input.30<input31 type="range" min={0} max={duration} value={time}32 aria-label="Seek"33 aria-valuetext={`${fmt(time)} of ${fmt(duration)}`}34 onChange={(e) => seek(Number(e.target.value))}35/>Framework-free HTML
<figure>
<video
controls
playsinline
preload="metadata"
poster="/rollback-poster.jpg"
width="1600"
height="900"
>
<source src="/rollback.webm" type="video/webm" />
<source src="/rollback.mp4" type="video/mp4" />
<track kind="captions" srclang="en" label="English"
src="/rollback.vtt" default />
<track kind="descriptions" srclang="en" label="Audio description"
src="/rollback-ad.vtt" />
<!-- The fallback is a real download, not an apology. -->
<p>Your browser cannot play this video.
<a href="/rollback.mp4">Download it instead</a>.</p>
</video>
<figcaption>
Rolling back a failed deployment · 3 min 34 s ·
<a href="/transcripts/rollback">Read the transcript</a>
</figcaption>
</figure>CSS
.ds-video {
position: relative;
/* Held before anything loads, exactly as for an image. */
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--radius-lg);
/* So letterboxing looks deliberate rather than like a gap. */
background: #000;
}
.ds-video video {
inline-size: 100%;
block-size: 100%;
object-fit: contain; /* never crop the frame */
}
/* White controls over an arbitrary frame have no assertable contrast. */
.ds-video__controls {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
padding: 24px 8px 6px;
background: linear-gradient(to top, rgb(0 0 0 / 0.8), transparent);
transition: opacity 200ms;
}
/* Must not hide while it holds focus — a keyboard user would lose the
control they are operating. */
.ds-video[data-playing='true'] .ds-video__controls { opacity: 0; }
.ds-video:hover .ds-video__controls,
.ds-video__controls:focus-within { opacity: 1; }
/* Above the control bar, never behind it. This is the most common video bug
and it appears exactly when the user reaches for the controls. */
video::cue { background: rgb(0 0 0 / 0.75); color: #fff; font-size: 1em; }
video::-webkit-media-text-track-container { inset-block-end: 56px; }
@media (prefers-reduced-motion: reduce) {
.ds-video--autoplay video { display: none; }
.ds-video--autoplay .ds-video__poster { display: block; }
}
@media (pointer: coarse) {
.ds-video__controls button { inline-size: 44px; block-size: 44px; }
}Component API
Video
| Prop | Type | Default | Description |
|---|---|---|---|
| src* | string | Source[] | — | WebM first, MP4 as the fallback. One format is a compatibility bet you do not need to take. |
| poster* | string | — | A real frame from the video. It holds the ratio and gives the user something to decide on. |
| captions* | Track[] | — | Required at level A. Include a descriptions track for anything shown but not spoken. |
| autoPlay | boolean | false | Only ever with muted and loop. Unmuted autoplay is blocked everywhere. |
| controls | boolean | true | Turning them off means the video cannot be paused. If it does not need controls, it should be an image. |
| preload | 'none' | 'metadata' | 'auto' | 'metadata' | Metadata is enough for the duration and the poster without downloading the file. |
| transcript | string | — | A link rendered beneath the player. Frequently more useful than the video itself. |
Professional tips
- Show the duration on the poster. "3:34" is what people use to decide whether to start, and its absence is why so many videos go unplayed.
- Keep product demos under two minutes. Completion falls off a cliff past that, and a long video is nearly always three short ones.
- Publish the transcript as a real page. It is indexable, translatable, and often the version people actually use.
- Never link a video as the only documentation of a process. Text can be searched, copied and followed at the reader’s pace.
- For short interaction demos, an optimised looping clip with no audio and no controls is often better as an animated image than as a video element.
Performance
- preload="metadata" gets you the duration and the poster without downloading the file. preload="auto" on a page with several videos will saturate the connection.
- Serve WebM with an MP4 fallback. The savings are substantial and the fallback is one extra source element.
- Never autoplay more than one video on a page. Each decode competes for the same main thread and the same battery.
- Use an intersection observer to pause off-screen videos. A looping clip playing behind the fold is pure waste.
- The poster is often the largest contentful paint on a video page — size and prioritise it like a hero image.
Common mistakes
- No captions, which fails WCAG 1.2.2 at level A.
- Unmuted autoplay, which is blocked everywhere and makes the player look broken.
- Missing playsInline, so iOS takes every video full screen on play.
- Captions positioned behind the control bar.
- A control bar that auto-hides while it still holds keyboard focus.
- A scrubber built from divs, losing arrow keys and value announcements.
- No poster, so the player is a black box with no ratio and no context.
- A background video shipped for atmosphere at several megabytes.
Real-world recommendations
- Captions are used most by people who can hear perfectly well — muted autoplay feeds, open offices, second languages. They are an accessibility requirement and a usability win at the same time.
- The native player is better than almost every custom one shipped. Custom players are usually built for branding and lose picture-in-picture, AirPlay, playback speed and half the keyboard model.
- Video is a poor primary format for documentation. It cannot be searched, copied or skimmed, and it dates the moment the UI changes.
- Background video in heroes tests badly and costs a lot. A still frame with a play control gets more engagement than an autoplaying loop nobody asked for.