video-player
Platform video player that wraps native <video> with custom overlay controls in shadow DOM. Progressive enhancement — native video works without JS.
Overview
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.
Single Video
Wrap a native <video> with optional <track> elements for captions.
Sizing
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.
Playlist Mode
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.
Shuffle and Loop
Controls Behavior
Unlike <audio-player> which shows a permanent control bar, video controls are an overlay that fades in and out:
- Visible for 3 seconds after any interaction (mousemove, focus, keypress)
- Always visible when paused, before first play, or after ended
- Touch devices: tap toggles visibility
- Cursor hides when controls are hidden during playback
prefers-reduced-motion: controls stay permanently visible
A gradient scrim at the bottom ensures controls are readable over any video content.
Attributes
| 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 |
Component-Managed Attributes
| 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 |
CSS Custom Properties
| 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).
Shadow Parts
| 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 |
Events
| 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 } |
Keyboard Shortcuts
| 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 |
Progressive Enhancement
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.
Track List Data Attributes
<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 |
Accessibility
- All controls use native
<button>or<input type="range">witharia-label - Controls group has
role="group"with label - Seek slider uses
aria-valuetextwith verbose time (e.g. "2 minutes 30 seconds") - Captions button uses
aria-pressedto reflect state - Speed button announces rate changes via
aria-live="polite" - Hidden
role="status"region announces "Playing", "Paused", "Buffering" - Keyboard navigable with documented shortcuts
- Respects
prefers-reduced-motion— no transitions, controls stay visible - Falls back to fully accessible native
<video>without JS - Captions rendered by native
<video>— browser handles text overlay positioning
Fullscreen
Fullscreen 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.
Related
<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