Welcome to Vanilla Breeze
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
Platform video player that wraps native <video> with custom overlay controls in shadow DOM. Progressive enhancement — native video works without JS.
A platform-native video player that wraps <video> in light DOM and renders custom overlay controls in shadow DOM. Controls fade in on interaction and auto-hide during playback. The native player is always the fallback — if JS is unavailable, <video controls> works normally with poster, captions, and fullscreen.
Wrap a native <video> with optional <track> elements for captions.
<video-player> <video controls poster="poster.jpg" width="960" height="540"> <source src="video.mp4" type="video/mp4"> <track kind="captions" src="en.vtt" srclang="en" label="English" default> <p><a href="video.mp4" download>Download video</a></p> </video></video-player>
Always include width and height attributes on the <video> element. These give the browser intrinsic dimensions before metadata loads, preventing layout shift — the same best practice as <img>.
For remote or large video files, the browser may not have loaded metadata (and therefore the video's native dimensions) by the time the page renders. Without dimension hints, the player collapses to zero height until the remote file responds.
The component also applies aspect-ratio: 16/9 as a CSS fallback. Once the video's actual aspect ratio is known from metadata or the poster image, it overrides the CSS default automatically. If your video is not 16:9, the width/height attributes ensure the correct ratio is used from the start.
A poster image is strongly recommended — it provides visual content before playback and helps the browser establish dimensions early.
Include a <details> with <ol class="track-list"> inside the component. Clicking a track updates the video source and plays it. Use data-poster and data-captions on track links for per-video switching.
<video-player> <video controls poster="ep1.jpg" width="960" height="540"> <source src="episodes/01.mp4" type="video/mp4"> <track kind="captions" src="captions/01-en.vtt" srclang="en" label="English" default> </video> <details> <summary>Episodes</summary> <ol class="track-list"> <li data-video-active> <a href="episodes/01.mp4" data-poster="ep1.jpg" data-captions="captions/01-en.vtt">01. Introduction</a> <span class="track-meta"><time datetime="PT12M30S">12:30</time></span> </li> <li> <a href="episodes/02.mp4" data-poster="ep2.jpg">02. Getting Started</a> <span class="track-meta"><time datetime="PT18M45S">18:45</time></span> </li> </ol> </details></video-player>
<video-player shuffle loop> <video controls poster="ep1.jpg" width="960" height="540"> <source src="episodes/01.mp4" type="video/mp4"> </video> <details open> <summary>Shuffle Playlist</summary> <ol class="track-list"> <li data-video-active><a href="episodes/01.mp4">Episode 1</a></li> <li><a href="episodes/02.mp4">Episode 2</a></li> <li><a href="episodes/03.mp4">Episode 3</a></li> </ol> </details></video-player>
Unlike <audio-player> which shows a permanent control bar, video controls are an overlay that fades in and out:
prefers-reduced-motion: controls stay permanently visibleA gradient scrim at the bottom ensures controls are readable over any video content.
| Attribute | Type | Description |
|---|---|---|
autoplay | Boolean | Start playing on load (subject to browser autoplay policy) |
loop | Boolean | Loop single video or entire playlist |
shuffle | Boolean | Randomize playlist order on track advance |
muted | Boolean | Start muted |
| Attribute | Values | Description |
|---|---|---|
state | idle, playing, paused, buffering, ended | Current playback state |
data-upgraded | Present | Component has initialized |
data-fullscreen | Present | Player is in fullscreen |
captions | Present | Captions are currently showing |
controls | Present | Overlay controls are visible |
muted | Present | Audio is muted |
| Property | Default | Description |
|---|---|---|
--video-player-accent | var(--color-primary) | Play button, timeline fill, active states |
--video-player-controls-bg | oklch(0% 0 0 / 0.75) | Controls bar background (semi-transparent dark) |
--video-player-controls-text | #fff | Controls text and icon color |
--video-player-radius | var(--radius-m) | Player border radius |
--video-player-border | none | Player border (off by default) |
--video-player-shadow | none | Player box shadow (opt-in) |
--video-player-controls-padding | var(--size-xs) var(--size-s) | Controls area padding |
--video-player-overlay-bg | oklch(0% 0 0 / 0.4) | Center play button backdrop |
--video-player-timeline-height | 4px | Timeline track height |
--video-player-timeline-buffer | oklch(100% 0 0 / 0.3) | Buffer progress color |
Video controls overlay the content, so defaults are semi-transparent dark with white text (unlike <audio-player> which uses surface/text tokens).
video-player { --video-player-accent: oklch(60% 0.2 30); --video-player-radius: var(--radius-l); --video-player-shadow: var(--shadow-lg); --video-player-controls-bg: oklch(0% 0 0 / 0.85); --video-player-timeline-height: 6px;}
| Part | Description |
|---|---|
player | Outer player container (position: relative wrapper) |
controls | Controls overlay at bottom |
play-overlay | Big center play button |
play-button | Play/pause button in controls row |
timeline | Seek range input |
volume | Volume range input |
time-display | Current time / duration display |
speed-button | Playback speed cycle button |
captions-button | Captions toggle button |
fullscreen-button | Fullscreen toggle button |
| Event | Detail |
|---|---|
video-player:play | { currentTime, src } |
video-player:pause | { currentTime } |
video-player:ended | { src } |
video-player:track-change | { src, title } |
video-player:fullscreen | { active: boolean } |
video-player:speed | { rate: number } |
video-player:captions | { active: boolean, label: string } |
const player = document.querySelector('video-player'); player.addEventListener('video-player:play', (e) => { console.log('Playing:', e.detail.src);}); player.addEventListener('video-player:track-change', (e) => { console.log('Now playing:', e.detail.title);}); player.addEventListener('video-player:fullscreen', (e) => { console.log('Fullscreen:', e.detail.active);}); player.addEventListener('video-player:speed', (e) => { console.log('Speed:', e.detail.rate);});
| Key | Action |
|---|---|
| Space / K | Play / Pause |
| Left Arrow | Seek back 10 seconds |
| Right Arrow | Seek forward 10 seconds |
| Up Arrow | Volume up 5% |
| Down Arrow | Volume down 5% |
| M | Toggle mute |
| F | Toggle fullscreen |
| C | Toggle captions |
| Escape | Exit fullscreen |
The <video> element lives in light DOM and is visible via <slot> (not hidden like <audio>). Before JS loads, native controls render with poster and captions. After the component upgrades, native controls are hidden and custom overlay chrome takes over. If the component is removed from the DOM, native controls are restored.
<!-- Without JS: native <video> controls render and work. Poster, captions, and track list links all function. --><video-player> <video controls poster="poster.jpg" width="960" height="540"> <source src="video.mp4" type="video/mp4"> <track kind="captions" src="en.vtt" srclang="en" label="English" default> <p><a href="video.mp4" download>Download video</a></p> </video></video-player>
<li> elements in .track-list use data attributes for state:
| Attribute | Set by | Meaning |
|---|---|---|
data-video-active | Component + author | Currently loaded/playing track |
data-video-played | Component | Track has been played this session |
data-video-favorite | Author | Editorially marked as a highlight |
Track <a> elements support optional attributes for per-track switching:
| Attribute | Description |
|---|---|
data-poster | Poster image URL for this track |
data-captions | Captions VTT file URL for this track |
<button> or <input type="range"> with aria-labelrole="group" with labelaria-valuetext with verbose time (e.g. "2 minutes 30 seconds")aria-pressed to reflect statearia-live="polite"role="status" region announces "Playing", "Paused", "Buffering"prefers-reduced-motion — no transitions, controls stay visible<video> without JS<video> — browser handles text overlay positioningFullscreen is requested on the host element (not the <video>), so the custom shadow DOM controls remain visible in fullscreen mode. The <video> fills the host via CSS.
<video> — Native video element (Layers 1-3)<audio-player> — Audio equivalent with same architecture<youtube-player> — YouTube embed with facade pattern (no <video> element)<track> — Captions, subtitles, and chapter tracks