Skip to content
Component installs need the registry setup
VegaStack Design

Media Player Controls

The shared media transport — play/pause, seek, time, mute + volume, speed, and one keyboard shortcut map.

Status
stable
Since
0.7.0
Accessibility pattern
native media controls

Last updated

0:00
0:00

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

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/media-player-controls

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

0:00
0:00

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.

0:00
0:00

API Reference

PropTypeDefaultDescription
mediaRef*React.RefObject<HTMLMediaElement | null>—Ref for the underlying <audio> or <video> element that these controls operate.
defaultPlaybackRatenumber1Initial playback rate applied when the media element mounts.
defaultQualitystring—Initial quality label selected in the settings menu.
formatTime((seconds: number) => string)mm:ss / h:mm:ssFormat elapsed and duration labels.
isFullscreenbooleanfalseWhether the associated media frame currently owns document fullscreen. Updates the fullscreen control's icon and accessible name.
labelstring'Media'Accessible label prefix used for transport controls and the seek slider.
loadingbooleanfalseThe 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.
playbackRatesreadonly number[][0.75, 1, 1.25, 1.5, 2]Playback rates cycled by the rate control.
qualityOptionsreadonly 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.
skipSecondsnumber15Seconds 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.
waveformFlatPeaksreadonly number[]—Flat placeholder amplitudes drawn while waveformPeaks is still empty (the decode is in flight, or it failed). Ignored unless seekVariant is waveform.
waveformPeaksreadonly 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

AttributeValues
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-variantmirrors 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().

PropTypeDefaultDescription
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.
skipSecondsnumber15Seconds 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

KeyAction
TabMove through play/pause, skip, seek, mute, speed, settings
Enter / SpaceActivate the focused control
KPlay or pause
J / LRewind / forward by skipSeconds
MToggle mute
FToggle fullscreen (when onFullscreenToggle is supplied)
← / →Seek by one step while the seek rail is focused
↑ / ↓Change volume while the volume rail is focused*
Home / EndSeek 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 label and 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-visible outline. 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 explicit aria-label — icon-button's compile-time guarantee went with it in Batch 7a, so the names are asserted in media-player-controls.test.tsx instead.
  • 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 / onQualityChange reach the settings menu, which only the overlay variant renders; in the default variant 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 gives TooltipContent and DropdownMenuContent a container pass-through, and the transport hands them document.fullscreenElement for 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.
ContractStates tested
Behaviourdefault, playing, paused, muted, volume, seeking, keyboard-shortcuts, playback-rate, quality, fullscreen, transcript
Accessibilityfocus-visible, keyboard-operable, labeled, semantic-html
Visualdefault, overlay, waveform, volume-open, compact

Do / Don't

Do
Compose MediaPlayerControls when you own the media element and want the system's transport, chrome, and keyboard map.
Don't
Install it to get a plain audio or video player — AudioPlayer and VideoPlayer already compose it for you.

On this page