Media Player Controls
The shared media transport — play/pause, seek, time, mute + volume, speed, and one keyboard shortcut map.
- Status
- Since
0.7.0- Accessibility pattern
- native media controls
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Media Player Controls before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify media-player-controlsInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/media-player-controlsRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/dropdown-menu, @vegastack/slider, @vegastack/spinner, @vegastack/tooltip.
Usage
MediaPlayerControls is the transport that AudioPlayer and VideoPlayer both compose. Install it
directly only when the player around it is yours — otherwise reach for one of those two.
import { useRef } from "react";
import { MediaPlayerControls } from "@/components/ui/media-player-controls";
const mediaRef = useRef<HTMLMediaElement>(null);
<audio ref={mediaRef} src="/media/demo.mp3" aria-label="Demo audio" />
<MediaPlayerControls mediaRef={mediaRef} label="Demo audio" />;Two visual treatments. variant="default" is the bordered audio card. variant="overlay" is chrome
drawn over video: it paints on the theme-invariant --media-scrim / --media-scrim-strong /
--media-foreground tokens, so the controls read dark-scrim + light-ink in both themes rather
than inverting in dark.
Examples
Default
Wide players lay the transport out on a single line: play/pause, rewind and forward, the elapsed·duration readout, the seek rail, mute + volume, and the tappable speed pill.
Narrow
Below the @sm container width the transport reflows to two lines — seek and timers on top,
a centred transport below with the transcript and volume controls on the leading edge. It is a
container query, so a narrow sidebar gets the mobile layout on a wide viewport.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
mediaRef* | React.RefObject<HTMLMediaElement | null> | — | Ref for the underlying <audio> or <video> element that these controls operate. |
defaultPlaybackRate | number | 1 | Initial playback rate applied when the media element mounts. |
defaultQuality | string | — | Initial quality label selected in the settings menu. |
formatTime | ((seconds: number) => string) | mm:ss / h:mm:ss | Format elapsed and duration labels. |
isFullscreen | boolean | false | Whether the associated media frame currently owns document fullscreen. Updates the fullscreen control's icon and accessible name. |
label | string | 'Media' | Accessible label prefix used for transport controls and the seek slider. |
loading | boolean | false | The source is loading or buffering. The play/pause glyph becomes a same-size spinner inside
the same button, which gains aria-busy and keeps its accessible name and its action. Nothing
else in the layout moves. |
onFullscreenToggle | (() => void) | — | Called when the fullscreen control is pressed. When omitted, the fullscreen control is not rendered. |
onPlaybackRateChange | ((playbackRate: number) => void) | — | Called whenever the playback rate changes. |
onPlayStateChange | ((playing: boolean) => void) | — | Called whenever playback starts or pauses. |
onQualityChange | ((quality: string) => void) | — | Called whenever the selected quality label changes. |
onTimeChange | ((currentTime: number, duration: number) => void) | — | Called whenever the current playback time changes. |
onTranscriptClick | (() => void) | — | Called when the narrow-layout transcript control is pressed. When omitted,
the transcript control is not rendered. Audio (default variant) only; the
button appears only on a narrow, mobile-width player. |
playbackRates | readonly number[] | [0.75, 1, 1.25, 1.5, 2] | Playback rates cycled by the rate control. |
qualityOptions | readonly string[] | — | Selectable video quality labels shown in the settings menu. |
seekVariant | "slider" | "waveform" | 'slider' | Seek control rendering. waveform swaps the seek slider for a decoded-audio
waveform (amplitude bars supplied via waveformPeaks); the slider keeps all
keyboard and pointer seek semantics beneath the bars. |
skipSeconds | number | 15 | Seconds moved by the rewind and forward actions. |
variant | "default" | "overlay" | 'default' | Visual treatment for the controls surface. overlay is tuned for video
controls placed over media: theme-invariant --media-* chrome on a scrim. |
waveformFlatPeaks | readonly number[] | — | Flat placeholder amplitudes drawn while waveformPeaks is still empty (the
decode is in flight, or it failed). Ignored unless seekVariant is
waveform. |
waveformPeaks | readonly number[] | — | Normalized (0–1) waveform peak amplitudes rendered by the waveform seek
variant. Ignored unless seekVariant is waveform. |
Data attributes and CSS variables on MediaPlayerControls
| Attribute | Values |
|---|---|
data-slot | "media-player-actions" | "media-player-actions-compact" | "media-player-controls" | "media-player-controls-layout" | "media-player-seek" | "media-player-skip-controls" | "media-player-time" | "media-player-time-duration" | "media-player-time-elapsed" | "media-player-transport" | "media-player-volume" | "media-player-volume-panel" | "media-player-volume-surface" |
data-state | "paused" | "playing" |
data-variant | mirrors a prop or state value |
useMediaShortcuts
The single keyboard map, exported so a custom media surface binds the same keys the controls do.
runShortcut(key, scope) returns whether a shortcut ran, so the caller decides whether to
preventDefault().
| Prop | Type | Default | Description |
|---|---|---|---|
mediaRef* | React.RefObject<HTMLMediaElement | null> | — | The <audio>/<video> element the shortcuts operate. |
onFullscreenToggle | (() => void) | — | Fullscreen toggle for F. When omitted, F is not handled. |
onPlayStateChange | ((playing: boolean) => void) | — | Called after a play/pause attempt that could not start playback. |
onTimeChange | ((currentTime: number, duration: number) => void) | — | Called after a shortcut changed the current time. |
skipSeconds | number | 15 | Seconds moved by J/L and the arrow keys. |
Scope is what keeps a shortcut from stealing a key from the control the user is on. surface — the
player frame, or the document while the frame owns keyboard attention — claims the whole map.
controls — inside the transport, where a focused button already owns Space and a focused seek rail
already owns the arrows — claims only the letter shortcuts.
Accessibility
| Key | Action |
|---|---|
| Tab | Move through play/pause, skip, seek, mute, speed, settings |
| Enter / Space | Activate the focused control |
| K | Play or pause |
| J / L | Rewind / forward by skipSeconds |
| M | Toggle mute |
| F | Toggle fullscreen (when onFullscreenToggle is supplied) |
| ← / → | Seek by one step while the seek rail is focused |
| ↑ / ↓ | Change volume while the volume rail is focused* |
| Home / End | Seek to the beginning / end |
* The volume rail is a native range in writing-mode: vertical-lr, so which arrow walks which
way is the platform's call rather than this component's; the suite drives it through the native
setter for that reason.
- The group is labelled from
labeland is not itself a tab stop — every control inside it is focusable, so a stop on the wrapper only added an empty one. - Focus is the centralized 2px
:focus-visibleoutline. Over video it is pulled inside (-outline-offset-2) because the frame clips it; the width, colour and token are unchanged. - Icon-only controls are
Button size="icon", and every one of them names itself with an explicitaria-label—icon-button's compile-time guarantee went with it in Batch 7a, so the names are asserted inmedia-player-controls.test.tsxinstead. - The seek thumb is hidden at rest only where a pointer can hover. On a touch device it stays visible, because there is no hover to reveal it with.
qualityOptions/defaultQuality/onQualityChangereach the settings menu, which only theoverlayvariant renders; in thedefaultvariant they are accepted and unused.- Tooltips name the keyboard shortcut alongside the action, and they appear in fullscreen. The
browser paints only the subtree passed to
requestFullscreen(), so a portal to<body>would be invisible there; decision OVL-14 givesTooltipContentandDropdownMenuContentacontainerpass-through, and the transport hands themdocument.fullscreenElementfor exactly as long as it contains them. There is no prop to wire: a page that fullscreens something else is untouched, and outside fullscreen both popups keep upstream's<body>default. The volume panel is inline and never depended on it.
| Contract | States tested |
|---|---|
| Behaviour | default, playing, paused, muted, volume, seeking, keyboard-shortcuts, playback-rate, quality, fullscreen, transcript |
| Accessibility | focus-visible, keyboard-operable, labeled, semantic-html |
| Visual | default, overlay, waveform, volume-open, compact |
Do / Don't
Carousel
A slide track with previous and next controls — horizontal or vertical, any slide size or spacing, plus Embla's options, events and plugins.
Audio Player
A custom audio transport with play/pause, skip, seek, a tappable speed control, and keyboard shortcuts — single-line on a wide player, two lines on mobile.