Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
1:12 / 3:34
Rolling back a failed deployment · 3 min 34 s · Read the transcript

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.

With poster
1:12 / 3:34
Rolling back a failed deployment · 3 min 34 s · Read the transcript
WithoutBlack box, no context
1:12 / 3:34
Rolling back a failed deployment · 3 min 34 s · Read the transcript

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.

1:12 / 3:34
Rolling back a failed deployment · 3 min 34 s · Read the transcript
Transcript

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.

muted + playsInline + loopPlays. The only autoplay that works.
autoplay with soundBlocked by every browser. Looks broken.
autoplay, no pause controlFails WCAG 2.2.2 past five seconds.
prefers-reduced-motionShow the poster, do not start.

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.

1:12 / 3:34

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.

Poster
Playing
Muted
Captions on
…completed in eight seconds.
Caption text
Scrubber
1:12 / 3:34
Time
Poster only
Reduced motion

Anatomy

Every part, every measurement, and the reason it is that number.

1:12 / 3:34
Rolling back a failed deployment · 3 min 34 s · Read the transcript

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.

  1. Aspect ratio16:9, reserved

    Held before anything loads, exactly as for an image. A player that resizes on load shifts everything below it.

  2. 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.

  3. Play affordance56px, centred

    Large and unmissable. It is the only control that matters before playback starts, and it must be a real button.

  4. Control bar40px over a scrim

    The gradient is not decoration — white controls over an arbitrary frame have no contrast without it.

  5. ScrubberReal input[type=range]

    Native, so arrow keys, Home, End and screen-reader value announcements all work without being rebuilt.

  6. 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.

  7. Transcript linkBelow the player

    Not required by WCAG, and frequently more useful than the video: searchable, skimmable, copyable and printable.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-2Control bar padding

Radius

TokenValueUsed for
--radius-lgPlayer corners

Typography

TokenValueUsed for
tabular-nums—Elapsed time, so it does not jitter every second

Motion

TokenValueUsed for
controls fadeAuto-hide during playback

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingTypeMin widthMax widthTouch targetWhen to use
Inline16:9———40rem—Inside prose, matched to the reading measure.
Full-width16:9——100%——A demo that is the subject of the page.
Thumbnail16:9——160px——In a list or a grid. Poster plus a duration badge, no controls.
Control bar40px0 8px———44px on coarse pointersOver a gradient scrim, since white on an arbitrary frame has no contrast.
Play affordance56px—————Centred, unmissable, and a real button.
Captions——13–16px———User-resizable. Positioned above the control bar, never behind it.

Do

<track kind="captions" srclang="en"
  label="English" src="/v.vtt" default />
Ship real captionsRequired at level A for prerecorded video with audio, and used far beyond their original audience — open offices, trains, second languages.
Use a real frame as the posterIt holds the aspect ratio, gives the user something to decide on, and costs a fraction of the video. A black box tells them nothing.
Offer a transcriptNot required, and often more useful than the video itself: searchable, skimmable, copyable, printable, and translatable.
@media (prefers-reduced-motion: reduce) {
  video { display: none } .poster { display: block }
}
Respect prefers-reduced-motionAn autoplaying loop can trigger vestibular symptoms. Show the poster and let the user start it deliberately.

Don't

<video autoplay> → blocked, shows a frozen frame
Do not autoplay with soundEvery browser blocks it, so the player appears broken rather than loud — and where it does play, it is the single most hostile thing a page can do.
no controls
Do not hide the controlsA video with no controls cannot be paused, scrubbed or muted. If it is decorative enough not to need them, it should be an image.
…hidden caption text
Do not let captions sit behind the controlsThe most common video bug, and it appears the moment the control bar fades in — exactly when the user reaches for it.
hero-loop.mp4 — 8.4 MB, plays on every visit
Do not ship a background video for atmosphereSeveral megabytes, sustained battery drain, no autoplay in low-power mode, and a decode that competes with rendering the text people came for.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.2.2Captions (Prerecorded)A1.2.3Audio Description or Media AlternativeA1.2.5Audio Description (Prerecorded)AA1.4.2Audio ControlA2.2.2Pause, Stop, HideA

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 / KPlay and pause. Both, because both are conventions users arrive with.
← / →Seek by five seconds. ↑ / ↓ adjusts volume.
Home / EndJump to the start or end.
MToggle mute. C toggles captions. F toggles full screen.
TabMoves 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.
AttributeApplied toNotes
<track kind="captions">The videoRequired at level A. Auto-generated captions are a starting point, not a delivery.
<track kind="descriptions">The videoAudio description for anything conveyed visually and not spoken — required at AA.
aria-labelEvery control"Play", "Mute", "Show captions". Icon-only controls over video are the least self-explanatory in any product.
aria-valuetextThe scrubber"1 minute 12 seconds of 3 minutes 34" — a bare 0–1 range value is meaningless.
aria-live="off"The elapsed timeExplicitly off. A time display that announces every second is unusable.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
autoPlaybooleanfalseOnly ever with muted and loop. Unmuted autoplay is blocked everywhere.
controlsbooleantrueTurning 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.
transcriptstring—A link rendered beneath the player. Frequently more useful than the video itself.

Notes

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.