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
Add Media Player Controls from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/media-player-controlsThe same command installs the registry items it composes: @vegastack/button, @vegastack/dropdown-menu, @vegastack/icon-button, @vegastack/slider, @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. |
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. |
portalContainer | React.RefObject<HTMLElement | null> | — | Element the tooltips and the settings menu portal into. Pass the fullscreen host (the video
frame) so they stay visible in fullscreen, where a portal to <body> renders nothing. |
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 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 compose
IconButton, which makes a missing accessible name a type error. - 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.
- Tooltips name the keyboard shortcut alongside the action.
| 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 |