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.
- Status
- Since
0.5.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 Audio Player before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify audio-playerInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/audio-playerRun 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/media-player-controls, @vegastack/popover, @vegastack/slider, @vegastack/spinner, @vegastack/tooltip, @vegastack/use-announcer.
Usage
Closing or pausing cancels queued lazy playback and seeks. Late source resolution cannot resume a cancelled request.
import { AudioPlayer } from "@/components/ui/audio-player";
<AudioPlayer src="/media/demo.mp3" label="Product demo audio" />;Anatomy
AudioPlayer exports several parts. Every exported part, with the data-slot it renders
(generated from the canonical source):
Examples
Basic
A single-line transport rendered statically on a card. Left to right: play/pause, rewind and
forward (±15s), the combined elapsed / duration readout, a flexible seek bar, and a tappable
speed control. Every transport control is a ghost Button, so the chrome stays quiet against the
card.
The docs preview uses a 90-second local audio fixture, so it can play without network access.
Mobile layout
Below the @sm container width the transport reflows to two lines while staying compact. The top
line carries the seek bar with elapsed and duration pinned to either edge in a smaller font;
the bottom line centres play/pause with rewind and forward flanking it, a transcript control on the
leading edge, and the tappable speed on the trailing edge. The transcript control renders only when
onTranscriptClick is supplied — wire it to open your app's transcript. The example is width-limited
so the mobile layout is visible on a desktop screen.
Waveform
Set variant="waveform" to render the decoded audio as the seek bar. The waveform is sampled
from the real audio via the Web Audio API — played bars fill as playback advances, and the bars
seek by click, drag, or keyboard just like the slider. Until decoding finishes (or if it fails),
flat placeholder bars render and the seek stays operable.
Stored peaks
Decoding downloads the whole file, which is wasteful for a long recording. Compute the peaks once
at upload with probeAudio from the media-probe lib (up to 200 values), store them with the
file, and pass them as peaks: the waveform draws at once and the file is only streamed when it
plays. Any count works; the loudest peak is drawn full height.
import { probeAudio } from "@/lib/media-probe";
const { duration, peaks } = await probeAudio(file); // at upload
<AudioPlayer src={url} variant="waveform" peaks={peaks} />;Still waveform
AudioWaveform draws the same stored peaks as a still picture — the player's bars in
muted-foreground, with no playback and no seek — for an audio file's card in a grid, where a
player would be too much. It fills its width (h-12 unless you size it) and reduces long peak
arrays to bars (48) so it keeps its gaps at card width; empty peaks draw flat placeholder bars.
It is decorative (aria-hidden): the card's title names the recording.
<MediaCard
size="lg"
title={file.name}
image={null}
fallback={<AudioWaveform peaks={file.peaks} className="h-1/2 px-4" />}
imageBadge={formatDuration(file.duration)}
/>Decode limit
Without peaks, the waveform variant decodes the file itself only up to maxDecodeBytes (20 MB).
A larger file — by its Content-Length, or as soon as the download passes the limit — is never
decoded, and the player shows the plain seek slider instead of a flat, fake waveform.
With copy
Use title and description when the player needs a visible track name or short context.
Docked with close
Set docked to pin the player to the bottom of its scroll column. It is position: sticky, so it
sits at the end of the column's content and stays on its bottom edge while the column scrolls, with
a border, the popover surface and a shadow, padded clear of the bottom safe-area inset. A docked
player is a landmark region named by label, so a screen-reader user can jump to it.
Pass open and onOpenChange to make it closable. The close button (named by closeLabel,
"Close player" by default) pauses playback, calls onOpenChange(false) and hands focus back to the
control that opened the player. A hidden player stays mounted and inert and slides out with the
docked-control pair, so its playback position survives a hide. Escape does not close it: Escape
belongs to whatever overlay the page opens above it.
Weekly sync
Priya opened with the renewal timeline and the two open questions from legal.
Marcus confirmed the pilot covers four regional teams, not six as first scoped.
The group agreed to move the security review ahead of the pricing call.
Dana will send the revised order form by Thursday.
Open item: who owns onboarding for the second cohort.
Next check-in is set for the week after the pilot starts.
Floating
variant="floating" is a one-line pill — back 10s, play (the filled primary), forward 10s, elapsed,
seek, total, a speed menu, a volume popover and close — with no visible title (title is not shown;
label still names the region). Every icon button is size-8, play is size-9, times are
tabular. The seek bar is a thin Slider with a buffered shade, a time readout on hover and a thumb
that appears on hover. Space plays or pauses and ←/→ move 5 seconds anywhere on the pill. It is centred in its container and sticky 16px above the bottom of its scroll column.
Render it as the last child of the column it floats over, so it centres on the content, not across
a sidebar. Below the sm breakpoint it spans the full width on the bottom edge, clear of the
safe-area inset, with tighter padding and no volume control. It always sits at the bottom of its flex column
(mt-auto), however short the page. With an end rail beside the content, set
--audio-player-inset-end on an ancestor to the width the rail takes and the pill centres on the
main column instead of overlapping the rail; inside AppShell, a RecordLayoutRail sets it (22rem)
automatically from the 5xl content width. Like docked, it is a region named by label.
Global player
To keep one recording playing while the reader moves between pages, mount AudioPlayerProvider
in the app shell above the routes and render GlobalAudioPlayer at the end of the main content
column. Any page calls useGlobalPlayer().open(track, { at }); opening the same id again seeks
instead of reloading. The pill's title links back to href through renderLink, so the jump is a
client navigation. useGlobalPlayerTime() reads the position from its own context — feed it to
Transcript's currentTime — and onSourceExpired on the track renews an expired signed URL.
Import the provider, the hooks and GlobalAudioPlayer from audio-player-global in the app shell:
that module is small and loads the player UI only once a recording opens (open() starts the load),
so routes that never play one ship no player code. audio-player re-exports the same names.
// app shell — import from "@/components/ui/audio-player-global"
<AudioPlayerProvider renderLink={(props) => <Link {...props} />}>
<main className="flex min-h-0 flex-1 flex-col overflow-auto">
{children}
<GlobalAudioPlayer />
</main>
</AudioPlayerProvider>;
// a meeting page
const player = useGlobalPlayer();
const time = useGlobalPlayerTime();
const track = {
id: meeting.id,
src: mintAudioUrl,
onSourceExpired: mintAudioUrl,
title: meeting.title,
href: `/meetings/${meeting.id}`,
};
<Button onClick={() => player.open(track)}>Play</Button>;
<Transcript
currentTime={player.track?.id === meeting.id ? time : undefined}
onSeek={(at) => player.open(track, { at })}
/>;In a list
deferControls renders a light play button — and the still waveform when peaks are given — until
the first play, then the full controls, with focus moved to their play button. Use it for a list of
recordings, where a full player on every row costs more than it gives. Any play (mediaRef,
actionsRef) mounts the controls as well. It does not apply to floating or docked.
<AudioPlayer
src={file.url}
label={file.name}
peaks={file.peaks}
deferControls
/>One at a time
Starting a player pauses any other that is playing — AudioPlayer, VideoPlayer, the global
player and the FileViewer stages alike — and nothing resumes by itself. exclusive={false} opts a
player out both ways (a preview that may play under another).
While the global player is open it sets --dock-inset-bottom (its height plus 8px) on the
AppShell root, so an ActionBar — a list's selection bar — floats 8px above the pill instead of
on it, and a selecting DataList keeps its last row clear of both.
Lazy source
src also takes a function that resolves a URL, for a short-lived signed URL you only want to
fetch when someone listens. It is called once, on the first play (or the first actionsRef seek),
and the result is kept. While it resolves the play button shows a spinner. To load a different
recording, remount the player with a new key.
<AudioPlayer
src={() => getSignedUrl(recording.id)}
label={`${recording.title} recording`}
/>Loading and error
The player renders its full layout from the start, and no state changes its height. loading —
and a lazy src resolving, or the media buffering after play — swaps the play button's glyph for
a spinner of the same size in the same button, which gets aria-busy and keeps its name and its
action. loadingLabel ("Loading audio…") is announced once to screen readers and never shown.
error renders an alert in the destructive ink with a "Try again" button (retryLabel). The retry
reloads the source, resolves a lazy src again and calls onRetry, where you clear error.
A signed URL can expire while the page is open. Pass onSourceExpired — an async function that
returns a fresh URL — and a media error calls it once: the player loads the new URL and resumes at
the same position, playing if it was. Only a second failure, or a rejection, shows the error line;
"Try again" and a new src arm the renewal again.
<AudioPlayer
label="Meeting recording"
src={mintAudioUrl}
onSourceExpired={mintAudioUrl}
/>Idle — press play to see the playing state.
Loading — the play button shows a spinner; nothing else moves.
Buffering — the same spinner, while playing.
Lazy source — resolving on the first play.
Error
Couldn't load the recording.
Seek from outside
To jump the player from a transcript line or a chapter list, attach actionsRef. Its seek(seconds, { play }), play() and pause() are the player's own: a seek made before the metadata loads is
queued and applied on loadedmetadata, and a lazy src is resolved first. mediaRef still
exposes the raw <audio> element for anything else, such as reading currentTime.
const actions = React.useRef<AudioPlayerActions>(null);
const media = React.useRef<HTMLAudioElement>(null);
<AudioPlayer
src={url}
label="Call recording"
actionsRef={actions}
mediaRef={media}
/>;
<Button onClick={() => actions.current?.seek(line.start, { play: true })}>
{line.text}
</Button>;Playback rates
Pass playbackRates and defaultPlaybackRate to control the options the tappable speed
control cycles through. Each tap advances to the next rate in order and wraps past the end.
Playground
Every AudioPlayer prop, auto-generated from its TypeScript types.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
src* | string | (() => Promise<string>) | — | Audio source URL, or a function that resolves one — a signed URL fetched
only when someone listens. The function is called once, on the first play
(or the first actionsRef seek), and its result is kept; "Try again"
calls it afresh. To load a different recording, remount the player with a
new key. |
actionsRef | React.Ref<AudioPlayerActions> | — | Imperative seek / play / pause for driving the player from outside. |
className | string | — | Classes applied to the outer player container. |
closeLabel | string | "Close player" | Accessible name of the close button. The close pauses playback, calls
onOpenChange(false) and returns focus to the element that opened the
player. |
defaultPlaybackRate | number | 1 | Initial playback rate applied when the audio element mounts. |
deferControls | boolean | false | Render a light play button — and, with peaks, the still AudioWaveform — until the first
play, then the full controls, with focus moved to their play button. For a list of
recordings, where a full player on every row costs more than it gives. Any play (mediaRef,
actionsRef, the global keys) mounts the controls too. Not for floating or docked. |
description | React.ReactNode | — | Optional visible description shown below the title. |
docked | boolean | false | Dock the player to the bottom of its scroll column: position: sticky
with a border, the popover surface and a shadow, padded clear of the
bottom safe-area inset. A docked player is a region named by label. |
error | React.ReactNode | — | A load failure. Renders a role="alert" line with a retry button. The player also shows
loadErrorLabel on its own when a lazy src rejects or the media fails to load. |
exclusive | boolean | true | One media at a time: starting this player pauses any other DS player that is playing
(AudioPlayer, VideoPlayer, GlobalAudioPlayer, the FileViewer stages), and starting one
of those pauses this one. false opts out both ways. Nothing resumes by itself. |
formatTime | ((seconds: number) => string) | mm:ss / h:mm:ss | Format elapsed and duration labels. |
label | string | 'Audio' | Accessible label used by the audio element and custom controls. |
loadErrorLabel | string | "Couldn’t load the recording" | The error line when the player's own load fails and no error is given. |
loading | boolean | false | The source is loading. The play button shows a spinner in place of its glyph (same size,
aria-busy) and loadingLabel is announced once; the layout does not change. A lazy src
that is resolving counts as loading too, and so does the media buffering after play. |
loadingLabel | string | "Loading audio…" | Announced once to screen readers when loading starts (not shown). |
maxDecodeBytes | number | 20971520 (20 MB) | Without peaks, the waveform variant downloads and decodes the file itself — only up to
this many bytes. A larger file shows the plain seek slider instead. |
mediaRef | React.Ref<HTMLAudioElement> | — | Ref for the hidden native <audio> media engine. |
onOpenChange | ((open: boolean) => void) | — | Called when the close button asks to hide the player. Passing it renders the close button. Escape does not close the player. |
onPlaybackRateChange | ((playbackRate: number) => void) | — | Called whenever the playback rate changes. |
onPlayStateChange | ((playing: boolean) => void) | — | Called whenever playback starts or pauses. |
onRetry | (() => void) | — | Called when the retry button is pressed, after the player reloads its
source (a lazy src is resolved again). Clear error here. |
onSourceExpired | (() => Promise<string>) | — | Renew an expired source. When the media fails to load after it had a URL — a signed URL
that has expired — the player calls this once, loads the URL it resolves, and resumes at the
same position (playing, if it was). A second failure, or a rejection, shows the error line;
"Try again" and a new src re-arm it. Without it, a media error goes straight to the error. |
onTimeChange | ((currentTime: number, duration: number) => void) | — | Called whenever the current playback time changes. |
onTranscriptClick | (() => void) | — | Called when the transcript control is pressed on a narrow, mobile-width player — wire it to open the consumer app's transcript. When omitted, the transcript control is not rendered. |
open | boolean | true | Whether the player is shown. It stays mounted while hidden (inert,
data-active="false", and a docked player slides out with the
docked-control pair), so playback state survives a hide. |
peaks | readonly number[] | — | Precomputed waveform amplitudes for the waveform variant — any count, scaled so the
loudest is full height (probeAudio from media-probe returns up to 200 at upload). With
them the player never downloads the file to decode it. |
playbackRates | readonly number[] | [1, 1.25, 1.5, 2, 0.5] | Playback rates cycled by the tappable speed control, in tap order. Each tap advances to the next entry and wraps past the end. |
ref | React.Ref<HTMLDivElement> | — | Ref for the outer player container. |
retryLabel | string | "Try again" | Label of the retry button shown with error. |
skipSeconds | number | 15 | Seconds moved by the visible rewind/forward buttons and the keyboard skip. |
title | React.ReactNode | — | Optional visible title shown above the transport. |
variant | "default" | "floating" | "waveform" | 'default' | Presentation. waveform renders a decoded-audio waveform in place of the
seek slider (its keyboard and pointer semantics are preserved beneath the
bars). floating sits at the bottom of its flex column (mt-auto) and
centres on the main column: set --audio-player-inset-end on an ancestor to
the width an end rail takes (inside AppShell a RecordLayout rail sets 22rem
for you). It is a one-line pill — transport, seek, speed, volume, close; title is not shown — centred in
its container and sticky 16px above the bottom of its scroll column; below
the sm breakpoint it spans the full width on the bottom edge. A floating
player is a region named by label. |
Data attributes and CSS variables on AudioPlayer
| Attribute | Values |
|---|---|
data-active | "false" | "true" |
data-docked | "" |
data-slot | "audio-player" | "audio-player-close" | "audio-player-error" | "audio-player-facade" | "audio-player-header" |
data-state | "error" | "idle" | "loading" |
data-variant | mirrors a prop or state value |
AudioPlayerProvider
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | The app shell. |
renderLink | ((props: { href: string; className: string; children: React.ReactNode; }) => React.ReactNode) | a plain `<a>` | Renders the title link — pass your router's link (for example
(props) => <Link {...props} />) so the jump back is a client navigation. |
GlobalAudioPlayer
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Classes for the floating pill. |
skipSeconds | number | 10 | Seconds moved by rewind and forward. |
AudioWaveform
| Prop | Type | Default | Description |
|---|---|---|---|
peaks* | readonly number[] | — | The recording's amplitudes — the same stored peaks the player takes (probeAudio from
media-probe), any count and range; the loudest is drawn full height. Empty draws the flat
placeholder bars. |
bars | number | 48 | The most bars drawn. Longer peaks are reduced to this many, each the loudest of its
stretch, so a card-width waveform keeps its gaps. Fewer are drawn when the width cannot fit
them at 2px a bar and a 1px gap, so a narrow waveform never thins to nothing. |
Data attributes and CSS variables on AudioWaveform
| Attribute | Values |
|---|---|
data-slot | "audio-waveform" |
--wave-peak | CSS custom property |
useGlobalPlayer
| Prop | Type | Default | Description |
|---|---|---|---|
close* | () => void | — | Stop and close the player. |
open* | (track: GlobalPlayerTrack, opts?: GlobalPlayerSeekOptions) => void | — | Load track into the player (or reuse it when the id matches) and play from at. |
pause* | () => void | — | Pause playback. |
play* | () => void | — | Resume playback. |
playing* | boolean | — | Whether it is playing. |
seek* | (seconds: number, opts?: { play?: boolean; }) => void | — | Jump the open recording to seconds; play defaults to true. |
track* | GlobalPlayerTrack | null | — | The recording in the player, or null when it is closed. |
Accessibility
| Key | Action |
|---|---|
| Tab | Move through close, play/pause, rewind, forward, seek, and speed |
| Enter / Space | Activate the focused button |
| K | Play or pause |
| J | Rewind 15 seconds |
| L | Forward 15 seconds |
| M | Toggle mute |
| ← / ↓ | Decrease the seek position by one second while the slider is focused |
| → / ↑ | Increase the seek position by one second while the slider is focused |
| Home | Seek to the beginning |
| End | Seek to the end |
- Pass a descriptive
label; it names the media element and prefixes every custom control. - The seek rail composes
Slider, so keyboard and screen-reader slider semantics stay intact. - Icon-only transport buttons are
Button size="icon", each carrying its ownaria-label. - Tooltips expose the primary keyboard shortcuts for pointer and keyboard users.
- Playback speed is a single tappable button that cycles the rates; its accessible name always announces the current speed.
- Rewind and forward are visible buttons on both layouts (inline on a wide player; flanking play/pause on the second line of a narrow, mobile-width player) and keyboard shortcuts anywhere inside the player (J / L).
- The transcript control appears only on the narrow layout and only when
onTranscriptClickis supplied; it carries an accessible name derived fromlabeland opens whatever the consumer wires. - Mute and a volume rail are present in both layouts. The rail is a vertical
Sliderthat opens from the mute button on hover or focus; mute also stays on the M key. (Before the 2026-09-07 audit, audio had no volume control at all and mute was keyboard-only.) - The seek thumb is hidden at rest only where a pointer can hover. On a touch device it stays visible — without it there is no scrub affordance at all.
- The native
<audio>element is used as the media engine and does not expose browser-native controls; VegaStack renders the consistent control surface. - A docked player is a
regionnamed bylabel, not a toolbar: every control keeps its own tab stop and the seek rail keeps its arrow keys. A hidden player isinert, so nothing invisible is focusable, and closing returns focus to the control that opened it. Escape does not close it. loadingis announced once through a polite live region (buffering is not announced), and the play button carriesaria-busywhile it shows the spinner;erroris arole="alert"line in the-textink, with the retry as a separate button after it.
| Contract | States tested |
|---|---|
| Behaviour | default, playing, paused, muted, volume, seeking, keyboard-shortcuts, playback-rate, transcript, docked, open, hidden, loading, error, lazy-source, queued-seek, floating, global-player, precomputed-peaks, decode-limit, still-waveform |
| Accessibility | focus-visible, keyboard-operable, labeled, semantic-html, landmark-region, status-announcement, alert, inert-when-hidden, focus-return |
| Visual | default, with-copy, waveform, docked, hidden, loading, error, floating |