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.
<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.
<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();
});
});
<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.
| 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
| 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-posteris 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 italt=""so it is not announced twice. -
An
<iframe>needs atitle. 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-covercrops. If the subject is near the edge of the frame it will be cut off, and there is no focal-point control. Prefercontainunless 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.
<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.