Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
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

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

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

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.
defaultQualitystringInitial 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.
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.
portalContainerReact.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.
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 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 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.
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