Video

A frame that reserves the right amount of space before anything loads, holds a <video> or an <iframe> at a fixed ratio, and optionally covers it with a poster until someone asks for it. The last part is markup only — Deck ships no video behaviour at all.

When to use it

Any time you put a video on a page. The frame's job is to stop the layout jumping when the player initialises, and that applies equally to a self-hosted file and to a YouTube embed.

The frame holds 16:9 whether or not the file loads
<div class="video" style="max-inline-size:32rem"> <video controls preload="metadata" poster="/assets/deck/docs/sample-3.jpg"> <source src="/assets/deck/docs/sample.mp4" type="video/mp4"> <track kind="captions" src="/assets/deck/docs/sample.vtt" srclang="en" label="English" default> Your browser cannot play this video. </video> </div>
<div class="video" style="max-inline-size:32rem">
  <video controls preload="metadata" poster="/assets/deck/docs/sample-3.jpg">
    <source src="/assets/deck/docs/sample.mp4" type="video/mp4">
    <track kind="captions" src="/assets/deck/docs/sample.vtt" srclang="en" label="English" default>
    Your browser cannot play this video.
  </video>
</div>

That example deliberately points at a file that is not there, so you can see what the frame does on its own: it reserves the space, stays 16:9, and shows the poster. This is the failure mode you want — a placeholder of the right size rather than a collapsed element and a page that reflows when the real file arrives.

Ratios and fit

The frame is aspect-ratio: var(--ratio, 16 / 9). Three modifiers change it, and --ratio takes anything else directly.

  • .video-square — 1:1, for a social embed.
  • .video-portrait — 9:16, for vertical video.
  • .video-wide — 21:9, for anamorphic footage.

Inside the frame, the media is object-fit: contain by default: the whole picture is visible and the mismatch shows as letterboxing against the frame's dark background. .video-cover switches a <video> to cover instead, filling the frame and cropping the overflow.

Three ratios. Note the letterboxing where the source does not match
A tiled roofline
Frost on a window
A pier at dusk
<div class="cluster" style="align-items:flex-start"> <div class="video video-square" style="inline-size:11rem"> <img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline"> </div> <div class="video video-portrait" style="inline-size:8rem"> <img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window"> </div> <div class="video video-wide" style="inline-size:16rem"> <img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk"> </div> </div>
<div class="cluster" style="align-items:flex-start">
  <div class="video video-square" style="inline-size:11rem">
    <img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline">
  </div>
  <div class="video video-portrait" style="inline-size:8rem">
    <img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window">
  </div>
  <div class="video video-wide" style="inline-size:16rem">
    <img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk">
  </div>
</div>

An <img> is not covered by the fill rule. The selector is .video > :is(video, iframe, embed, object) — no img. The examples above use images to show the ratios without loading five video files, and they are sized by the frame rather than positioned by it. For a real poster image use .video-poster, which does handle it.

The poster overlay

A third-party embed loads a player, a tracker and several hundred kilobytes the moment the page renders, whether or not anyone watches. .video-poster is the façade that avoids it: a full-bleed image, a gradient scrim, a play button, and no embed until it is clicked.

Deck supplies the appearance and none of the behaviour. There is no script watching for a click on .video-poster. Swapping the poster for the real player is a handful of lines you write:

document.querySelectorAll('.video-poster').forEach(btn => {
  btn.addEventListener('click', () => {
    const frame = btn.closest('.video');
    const iframe = document.createElement('iframe');
    iframe.src = btn.dataset.src + '?autoplay=1';
    iframe.allow = 'autoplay; fullscreen; picture-in-picture';
    iframe.title = btn.dataset.title;
    frame.append(iframe);
    btn.remove();
  });
});
A real button with a real label — the image alt is empty because the button is named
12:04
<div class="video" style="max-inline-size:30rem"> <button type="button" class="video-poster" aria-label="Play: Building a component library in an afternoon"> <img src="/assets/deck/docs/sample-1.jpg" alt=""> <span class="video-play"> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg> </span> </button> <span class="video-meta"> <span class="video-duration">12:04</span> </span> </div>
<div class="video" style="max-inline-size:30rem">
  <button type="button" class="video-poster"
          aria-label="Play: Building a component library in an afternoon">
    <img src="/assets/deck/docs/sample-1.jpg" alt="">
    <span class="video-play">
      <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
    </span>
  </button>
  <span class="video-meta">
    <span class="video-duration">12:04</span>
  </span>
</div>

Deck's sprite has no transport icons. There is no play, pause, stop, mute or fullscreen symbol in deck-icons.svg — the 77 icons are interface and status glyphs, and media controls are not among them. The examples here use an inline triangle. That is not a workaround so much as the honest answer: the native controls attribute draws its own, and a poster façade needs exactly one custom glyph.

.video-play is a 64px disc using backdrop-filter: blur(8px) over a 90%-opaque surface colour. Where backdrop-filter is unsupported the blur is skipped and the disc is still nearly opaque, so it stays legible — the effect degrades rather than failing.

.video-meta is a bottom-start overlay for a duration or a channel name, and .video-duration is the dark pill inside it. Both sit above the poster's scrim, which is what makes white text readable over an arbitrary image.

Classes

Generated from src/24-media.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.video deck.components A responsive frame for a native video or an embed, plus styling for the native controls where the browser allows it. 24-media.css:11
.video-cover deck.components No declarations. 24-media.css:28
.video-duration deck.components Sets padding, border-radius, background, font-variant-numeric. 24-media.css:82
.video-meta deck.components Sets position, inset-block-end, inset-inline-start, z-index, and 5 more. 24-media.css:71
.video-play deck.components Sets position, z-index, inline-size, block-size, and 9 more. 24-media.css:55
.video-portrait deck.components Sets --ratio. 24-media.css:30
.video-poster deck.components Poster overlay with a play button, for a click-to-load embed 24-media.css:37
.video-square deck.components Sets --ratio. 24-media.css:29
.video-wide deck.components Sets --ratio. 24-media.css:31

Tokens

Design tokens this component reads
Token Live Declared value
--ink-950 oklch(16.2% calc(var(--chroma-neutral) * 2.0) var(--hue-neutral))
--surface light-dark(var(--ink-0), var(--ink-950))
--text light-dark(var(--ink-900), var(--ink-100))
--brand-500 oklch(60.8% var(--chroma-brand) var(--hue-brand))
--shadow-3 0 4px 8px oklch(20% .03 var(--shadow-color) / .06), 0 12px 28px oklch(20% .03 var(--shadow-color) / .10)
--r-md 12px
--r-full 999px
--r-xs 4px
--text-xs .75rem
--dur-2 190ms

Accessibility

  • Captions are not optional, and Deck cannot provide them. A <track kind="captions"> on a self-hosted video, or captions enabled on the hosted one. Everything else on this list is smaller than this.
  • Use controls. The native control set is keyboard operable, screen-reader labelled and familiar. Deck restyles only the WebKit panel background and the accent colour, precisely so the rest keeps working.
  • The poster must be a real <button>. .video-poster is styled as one — border and padding reset — but the class does not make it focusable. A <div> here is unreachable by keyboard.
  • Name the button with the video's title. aria-label="Play: …". If the poster image is decorative alongside that label, give it alt="" so it is not announced twice.
  • An <iframe> needs a title. Without one it is announced as an unlabelled frame. The snippet above sets it when the embed is created.
  • Do not autoplay with sound. If a video must autoplay it has to be muted and it has to be stoppable. Motion that starts on its own is covered by Reduced motion, and it is also simply rude.
  • .video-cover crops. If the subject is near the edge of the frame it will be cut off, and there is no focal-point control. Prefer contain unless you know the footage.
  • The duration overlay is real text and is announced. "12:04" read aloud in the middle of a link name is confusing, so keep it outside the button — as the example does — or hide it and put the duration in the button's label.

Right to left

The frame itself has no direction. .video-meta is placed with inset-inline-start, so the duration moves to the right-hand corner under dir="rtl" without a rule in src/19-logical.css.

The play triangle keeps pointing right, and that is deliberate. src/19-logical.css auto-mirrors ten icons from Deck's sprite by name — the chevrons, the arrows, #external, #log-out, #send and the two trend icons — and #play is not one of them. A media transport is a timeline rather than a sentence, and it runs left to right in every locale, so a mirrored play button would be pointing at the wrong end of the video.

If you do want a directional icon of your own to flip, that is .mirror-rtl; .no-flip is the escape hatch in the other direction, for an icon inside an ancestor you have already mirrored.

The duration pill moves to the right corner
١٢:٠٤
<div dir="rtl" class="video" style="max-inline-size:24rem"> <button type="button" class="video-poster" aria-label="تشغيل: بناء مكتبة مكوّنات"> <img src="/assets/deck/docs/sample-4.jpg" alt=""> <span class="video-play"> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg> </span> </button> <span class="video-meta"><span class="video-duration">١٢:٠٤</span></span> </div>
<div dir="rtl" class="video" style="max-inline-size:24rem">
  <button type="button" class="video-poster" aria-label="تشغيل: بناء مكتبة مكوّنات">
    <img src="/assets/deck/docs/sample-4.jpg" alt="">
    <span class="video-play">
      <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
    </span>
  </button>
  <span class="video-meta"><span class="video-duration">١٢:٠٤</span></span>
</div>

Reduced motion

Deck's own motion here is one transition: .video-play scales to 1.08 on hover over 190ms, collapsed by the global reset under prefers-reduced-motion: reduce.

The video itself is the real question and the stylesheet cannot answer it. A prefers-reduced-motion setting is a request not to be shown unexpected movement, and an autoplaying video is the largest possible violation of it. If you autoplay anything — a background loop, a silent demo — gate it in script on matchMedia('(prefers-reduced-motion: reduce)') and show the poster instead.

Printing

.video does not print. It is not in src/99-print.css — but it is in a second @layer deck.print block at the end of src/24-media.css, in the same display: none !important list as .drawer and .mega. Frame, poster, play button and duration all go.

That is a defensible default, since a video is the one thing paper genuinely cannot reproduce. It also means a printed article silently loses its illustrations, which is not always what you want — a tutorial whose screenshots are video posters prints with gaps in the argument.

@media print {
  /* Print the poster, drop the parts that imply interaction */
  .video { display: block !important; }
  .video-play, .video-poster::after, .video-meta { display: none; }
}

display: block rather than revert, because the rule you are overriding carries !important and you need the same weight to beat it.

Overriding it

/* One frame, no layer needed */
<div class="video" style="--ratio:4/3"> … </div>

@layer app.components {
  /* A lighter frame for a light-only site */
  .video { background: var(--ink-200); }

  /* Bigger play affordance on touch */
  @media (pointer: coarse) {
    .video-play { inline-size: 76px; block-size: 76px; }
  }

  /* Let an <img> fill the frame like the media does */
  .video > img { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; object-fit: cover; }
}

When not to use it

  • Not for an animated GIF or a decorative loop. Those are images with no controls; wrap them in a plain figure and respect reduced motion.
  • Not for audio. An <audio> element has no picture, so a ratio-locked frame reserves space for nothing.
  • Not without captions. See Accessibility. This is the one that makes a video usable or not.
  • Not with a poster you have not wired up. A play button that does nothing is worse than no façade at all — Deck supplies no click handler.
  • Not as a background. Video behind text is a contrast problem that changes every frame, and it is motion the reader did not ask for.