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.

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

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.

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

Shuffle and Loop

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

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

AttributeTypeDescription
autoplayBooleanStart playing on load (subject to browser autoplay policy)
loopBooleanLoop single video or entire playlist
shuffleBooleanRandomize playlist order on track advance
mutedBooleanStart muted

Component-Managed Attributes

AttributeValuesDescription
stateidle, playing, paused, buffering, endedCurrent playback state
data-upgradedPresentComponent has initialized
data-fullscreenPresentPlayer is in fullscreen
captionsPresentCaptions are currently showing
controlsPresentOverlay controls are visible
mutedPresentAudio is muted

CSS Custom Properties

PropertyDefaultDescription
--video-player-accentvar(--color-primary)Play button, timeline fill, active states
--video-player-controls-bgoklch(0% 0 0 / 0.75)Controls bar background (semi-transparent dark)
--video-player-controls-text#fffControls text and icon color
--video-player-radiusvar(--radius-m)Player border radius
--video-player-bordernonePlayer border (off by default)
--video-player-shadownonePlayer box shadow (opt-in)
--video-player-controls-paddingvar(--size-xs) var(--size-s)Controls area padding
--video-player-overlay-bgoklch(0% 0 0 / 0.4)Center play button backdrop
--video-player-timeline-height4pxTimeline track height
--video-player-timeline-bufferoklch(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; }

Shadow Parts

PartDescription
playerOuter player container (position: relative wrapper)
controlsControls overlay at bottom
play-overlayBig center play button
play-buttonPlay/pause button in controls row
timelineSeek range input
volumeVolume range input
time-displayCurrent time / duration display
speed-buttonPlayback speed cycle button
captions-buttonCaptions toggle button
fullscreen-buttonFullscreen toggle button

Events

EventDetail
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); });

Keyboard Shortcuts

KeyAction
Space / KPlay / Pause
Left ArrowSeek back 10 seconds
Right ArrowSeek forward 10 seconds
Up ArrowVolume up 5%
Down ArrowVolume down 5%
MToggle mute
FToggle fullscreen
CToggle captions
EscapeExit 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.

<!-- 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>

Track List Data Attributes

<li> elements in .track-list use data attributes for state:

AttributeSet byMeaning
data-video-activeComponent + authorCurrently loaded/playing track
data-video-playedComponentTrack has been played this session
data-video-favoriteAuthorEditorially marked as a highlight

Track <a> elements support optional attributes for per-track switching:

AttributeDescription
data-posterPoster image URL for this track
data-captionsCaptions VTT file URL for this track

Accessibility

  • All controls use native <button> or <input type="range"> with aria-label
  • Controls group has role="group" with label
  • Seek slider uses aria-valuetext with verbose time (e.g. "2 minutes 30 seconds")
  • Captions button uses aria-pressed to 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