A compact audio player with shadcn controls, built on the Video Player engine.
Add the media engine, shadcn primitives, styles, hook, and component.
Vidstack provides the headless player, the same engine as the Video Player.
bun add @vidstack/react@next lucide-reactThe visible controls use your existing theme tokens.
bunx --bun shadcn@latest add button dropdown-menu slider tooltipAdd this line to your global stylesheet.
@import "@vidstack/react/player/styles/base.css";The audio player shares it for playback state and time formatting. Save as components/ui/video-player/use-video-player.ts.
"use client";
import type { MouseEvent, PointerEvent } from "react";
import { useState } from "react";
import { useMediaRemote, useMediaStore } from "@vidstack/react";
const FALLBACK_DURATION = 0;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function formatMediaTime(value: number): string {
if (!Number.isFinite(value) || value < 0) return "0:00";
const totalSeconds = Math.floor(value);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
type VideoPlayerController = {
media: ReturnType<typeof useMediaStore>;
seekMin: number;
seekMax: number;
seekValue: number;
bufferedPercent: number;
togglePlayback: (event: MouseEvent<HTMLButtonElement>) => void;
seekBy: (seconds: number, event: MouseEvent<HTMLButtonElement>) => void;
previewSeek: (values: number[]) => void;
commitSeek: (values: number[]) => void;
startPointerSeek: (event: PointerEvent<HTMLElement>) => void;
movePointerSeek: (event: PointerEvent<HTMLElement>) => void;
finishPointerSeek: (event: PointerEvent<HTMLElement>) => void;
changeVolume: (values: number[]) => void;
toggleMuted: (event: MouseEvent<HTMLButtonElement>) => void;
toggleFullscreen: (event: MouseEvent<HTMLButtonElement>) => void;
togglePictureInPicture: (event: MouseEvent<HTMLButtonElement>) => void;
changePlaybackRate: (rate: number) => void;
changeQuality: (index: number) => void;
changeAudioTrack: (index: number) => void;
changeTextTrack: (trackId: string | null) => void;
toggleCaptions: (event: MouseEvent<HTMLButtonElement>) => void;
toggleLoop: () => void;
seekToLiveEdge: () => void;
requestAirPlay: () => void;
requestGoogleCast: () => void;
handleMenuOpenChange: (open: boolean) => void;
};
export function useVideoPlayer(): VideoPlayerController {
const remote = useMediaRemote();
const media = useMediaStore();
const [pendingSeek, setPendingSeek] = useState<number | null>(null);
const [preferredCaptionId, setPreferredCaptionId] = useState<string | null>(null);
const seekMin = media.live && media.isLiveDVR ? media.seekableStart : 0;
const seekMax = Number.isFinite(media.duration)
? media.duration
: Number.isFinite(media.seekableEnd)
? media.seekableEnd
: FALLBACK_DURATION;
const seekValue = clamp(pendingSeek ?? media.currentTime, seekMin, Math.max(seekMin, seekMax));
const seekWindow = Math.max(seekMax - seekMin, 0);
const bufferedPercent =
seekWindow > 0 ? clamp(((media.bufferedEnd - seekMin) / seekWindow) * 100, 0, 100) : 0;
function togglePlayback(event: MouseEvent<HTMLButtonElement>): void {
remote.togglePaused(event.nativeEvent);
}
function seekBy(seconds: number, event: MouseEvent<HTMLButtonElement>): void {
remote.seek(clamp(media.currentTime + seconds, seekMin, seekMax), event.nativeEvent);
}
function previewSeek(values: number[]): void {
const nextTime = values[0] ?? seekMin;
setPendingSeek(nextTime);
remote.seeking(nextTime);
}
function commitSeek(values: number[]): void {
remote.seek(values[0] ?? seekMin);
setPendingSeek(null);
}
function seekFromPointerPosition(event: PointerEvent<HTMLElement>): void {
const bounds = event.currentTarget.getBoundingClientRect();
if (bounds.width <= 0 || seekMax <= seekMin) return;
const progress = clamp((event.clientX - bounds.left) / bounds.width, 0, 1);
remote.seek(seekMin + progress * (seekMax - seekMin), event.nativeEvent);
}
function startPointerSeek(event: PointerEvent<HTMLElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
seekFromPointerPosition(event);
}
function movePointerSeek(event: PointerEvent<HTMLElement>): void {
if (event.currentTarget.hasPointerCapture(event.pointerId)) seekFromPointerPosition(event);
}
function finishPointerSeek(event: PointerEvent<HTMLElement>): void {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
seekFromPointerPosition(event);
event.currentTarget.releasePointerCapture(event.pointerId);
}
function changeVolume(values: number[]): void {
const nextVolume = clamp((values[0] ?? 0) / 100, 0, 1);
if (nextVolume > 0 && media.muted) remote.unmute();
remote.changeVolume(nextVolume);
}
function toggleMuted(event: MouseEvent<HTMLButtonElement>): void {
remote.toggleMuted(event.nativeEvent);
}
function toggleFullscreen(event: MouseEvent<HTMLButtonElement>): void {
remote.toggleFullscreen("prefer-media", event.nativeEvent);
}
function togglePictureInPicture(event: MouseEvent<HTMLButtonElement>): void {
remote.togglePictureInPicture(event.nativeEvent);
}
function changePlaybackRate(rate: number): void {
remote.changePlaybackRate(rate);
}
function changeQuality(index: number): void {
if (index < 0) {
remote.requestAutoQuality();
return;
}
remote.changeQuality(index);
}
function changeAudioTrack(index: number): void {
remote.changeAudioTrack(index);
}
function changeTextTrack(trackId: string | null): void {
if (trackId === null) {
if (media.textTrack) setPreferredCaptionId(media.textTrack.id);
remote.disableCaptions();
return;
}
const trackIndex = media.textTracks.findIndex((track) => track.id === trackId);
if (trackIndex >= 0) {
setPreferredCaptionId(trackId);
remote.changeTextTrackMode(trackIndex, "showing");
}
}
function toggleCaptions(event: MouseEvent<HTMLButtonElement>): void {
if (media.textTrack) {
setPreferredCaptionId(media.textTrack.id);
remote.disableCaptions(event.nativeEvent);
return;
}
const preferredTrackIndex = media.textTracks.findIndex(
(track) => track.id === preferredCaptionId,
);
const fallbackTrackIndex = media.textTracks.findIndex(
(track) => track.kind === "captions" || track.kind === "subtitles",
);
const trackIndex = preferredTrackIndex >= 0 ? preferredTrackIndex : fallbackTrackIndex;
if (trackIndex >= 0) {
setPreferredCaptionId(media.textTracks[trackIndex]?.id ?? null);
remote.changeTextTrackMode(trackIndex, "showing", event.nativeEvent);
}
}
function toggleLoop(): void {
remote.userPrefersLoopChange(!media.loop);
}
function seekToLiveEdge(): void {
remote.seekToLiveEdge();
}
function requestAirPlay(): void {
remote.requestAirPlay();
}
function requestGoogleCast(): void {
remote.requestGoogleCast();
}
function handleMenuOpenChange(open: boolean): void {
if (open) remote.pauseControls();
else remote.resumeControls();
}
return {
media,
seekMin,
seekMax,
seekValue,
bufferedPercent,
togglePlayback,
seekBy,
previewSeek,
commitSeek,
startPointerSeek,
movePointerSeek,
finishPointerSeek,
changeVolume,
toggleMuted,
toggleFullscreen,
togglePictureInPicture,
changePlaybackRate,
changeQuality,
changeAudioTrack,
changeTextTrack,
toggleCaptions,
toggleLoop,
seekToLiveEdge,
requestAirPlay,
requestGoogleCast,
handleMenuOpenChange,
};
}
Save as components/ui/audio-player/audio-player.tsx.
"use client";
import { useEffect, useRef, type ComponentProps, type ReactNode, type RefObject } from "react";
import { MediaAnnouncer, MediaPlayer, MediaProvider, type MediaPlayerProps } from "@vidstack/react";
import {
Download,
EllipsisVertical,
LoaderCircle,
Pause,
Play,
RotateCcw,
Volume1,
Volume2,
VolumeX,
} from "lucide-react";
import { Button } from "../button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../dropdown-menu";
import { Slider } from "../slider";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip";
import { cn } from "@/lib/utils";
import { formatMediaTime, useVideoPlayer } from "../video-player/use-video-player";
const DEFAULT_PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
const KEY_SHORTCUTS = {
togglePaused: "k Space",
toggleMuted: "m",
seekBackward: "j J ArrowLeft",
seekForward: "l L ArrowRight",
};
export type AudioPlayerProps = Omit<
MediaPlayerProps,
"children" | "className" | "controls" | "src" | "title" | "viewType"
> & {
src: MediaPlayerProps["src"];
title: string;
playbackRates?: readonly number[];
download?: boolean | string;
className?: string;
};
// Audio files are direct sources, so the download link is the file itself or an explicit url.
function getDownloadHref(
source: AudioPlayerProps["src"],
download: AudioPlayerProps["download"],
): string | null {
if (typeof download === "string") return download;
if (!download) return null;
const firstSource = Array.isArray(source) ? source[0] : source;
if (typeof firstSource === "string") return firstSource;
if (!firstSource || typeof firstSource.src !== "string") return null;
return firstSource.src;
}
// The shadcn slider labels its root only. Radix names a thumb from its own props, and a slider with one
// value gets no name, so the thumb's name and time text are set on it after each render.
function useSliderThumbAria(
rootRef: RefObject<HTMLElement | null>,
label: string,
valueText: string,
): void {
useEffect(() => {
rootRef.current?.querySelectorAll('[role="slider"]').forEach((thumb) => {
thumb.setAttribute("aria-label", label);
thumb.setAttribute("aria-valuetext", valueText);
});
});
}
function BarButton({
label,
children,
className,
...props
}: ComponentProps<typeof Button> & {
label: string;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={label}
className={cn("shrink-0", className)}
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
);
}
function OptionsMenu({
player,
playbackRates,
downloadHref,
disabled,
}: {
player: ReturnType<typeof useVideoPlayer>;
playbackRates: readonly number[];
downloadHref: string | null;
disabled: boolean;
}) {
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="More options"
disabled={disabled}
className="shrink-0"
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
More options
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" side="top" sideOffset={8}>
<DropdownMenuLabel>Playback speed</DropdownMenuLabel>
<DropdownMenuRadioGroup value={String(player.media.playbackRate)}>
{playbackRates.map((rate) => (
<DropdownMenuRadioItem
key={rate}
value={String(rate)}
disabled={!player.media.canSetPlaybackRate}
onSelect={() => player.changePlaybackRate(rate)}
>
{rate === 1 ? "Normal" : `${rate}×`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{downloadHref && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href={downloadHref} download>
<Download />
Download
</a>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AudioBar({
title,
playbackRates,
downloadHref,
}: {
title: string;
playbackRates: readonly number[];
downloadHref: string | null;
}) {
const player = useVideoPlayer();
const { media } = player;
const seekRef = useRef<HTMLDivElement>(null);
const volumeRef = useRef<HTMLDivElement>(null);
const failed = Boolean(media.error);
const loading = !failed && !media.canPlay;
const playLabel = loading ? "Loading" : media.ended ? "Replay" : media.paused ? "Play" : "Pause";
const volumeLabel = media.muted ? "Unmute" : "Mute";
const VolumeIcon = media.muted ? VolumeX : media.volume < 0.5 ? Volume1 : Volume2;
const volumePercent = Math.round((media.muted ? 0 : media.volume) * 100);
useSliderThumbAria(
seekRef,
"Audio progress",
`${formatMediaTime(player.seekValue)} of ${formatMediaTime(player.seekMax)}`,
);
useSliderThumbAria(volumeRef, "Volume", `${volumePercent}%`);
let playIcon: ReactNode;
if (media.ended) playIcon = <RotateCcw />;
else if (media.paused) playIcon = <Play className="fill-current" />;
else playIcon = <Pause className="fill-current" />;
if (loading || (media.waiting && !media.paused)) {
playIcon = <LoaderCircle className="animate-spin motion-reduce:animate-none" />;
}
return (
<TooltipProvider>
<div
role="group"
aria-label={title}
className="flex h-14 w-full items-center gap-1 px-3 @sm/audio-player:gap-2 @sm/audio-player:px-4"
>
<BarButton label={playLabel} onClick={player.togglePlayback} disabled={failed || loading}>
{playIcon}
</BarButton>
{failed ? (
<p role="alert" className="text-muted-foreground min-w-0 flex-1 truncate text-sm">
{"This audio can't be played."}
</p>
) : (
<>
<span className="text-muted-foreground shrink-0 text-right font-mono text-xs tabular-nums">
{formatMediaTime(media.currentTime)}
</span>
<div ref={seekRef} className="flex min-w-0 flex-1">
<Slider
min={player.seekMin}
max={Math.max(player.seekMax, player.seekMin + 0.01)}
step={0.1}
value={[player.seekValue]}
disabled={!media.canSeek}
onValueChange={player.previewSeek}
onValueCommit={player.commitSeek}
className="cursor-pointer"
/>
</div>
<span className="text-muted-foreground shrink-0 font-mono text-xs tabular-nums">
{formatMediaTime(media.duration)}
</span>
</>
)}
<BarButton label={volumeLabel} onClick={player.toggleMuted} disabled={failed}>
<VolumeIcon />
</BarButton>
<div ref={volumeRef} className="hidden w-20 shrink-0 @sm/audio-player:flex">
<Slider
min={0}
max={100}
step={1}
value={[volumePercent]}
disabled={failed || !media.canSetVolume}
onValueChange={player.changeVolume}
/>
</div>
<OptionsMenu
player={player}
playbackRates={playbackRates}
downloadHref={downloadHref}
disabled={failed}
/>
</div>
</TooltipProvider>
);
}
// A compact audio player: one row of shadcn controls over the vidstack engine, with no visible surface.
export function AudioPlayer({
src,
title,
playbackRates = DEFAULT_PLAYBACK_RATES,
download = false,
className,
crossOrigin = true,
load = "visible",
preload = "metadata",
keyTarget = "player",
keyShortcuts = KEY_SHORTCUTS,
...playerProps
}: AudioPlayerProps) {
const downloadHref = getDownloadHref(src, download);
return (
<MediaPlayer
src={src}
title={title}
viewType="audio"
crossOrigin={crossOrigin}
load={load}
preload={preload}
keyTarget={keyTarget}
keyShortcuts={keyShortcuts}
className={cn(
"group/audio-player bg-background text-foreground focus-visible:ring-ring/50 @container/audio-player relative isolate block w-full min-w-0 rounded-full border outline-none focus-visible:ring-3",
className,
)}
{...playerProps}
>
<MediaProvider />
<MediaAnnouncer />
<AudioBar title={title} playbackRates={playbackRates} downloadHref={downloadHref} />
</MediaPlayer>
);
}
Pass a title and a source. The download link and playback rates are optional.
"use client";
import { AudioPlayer } from "@/components/ui/audio-player/audio-player";
export function Example() {
return (
<AudioPlayer
title="Field recording"
src={{ src: "/audio/recording.mp3", type: "audio/mpeg" }}
download
/>
);
}Shortcuts apply while focus is inside the player. Tab reaches every control.
| Keys | Action |
|---|---|
| Space / K | Play or pause |
| J / ← | Seek backward |
| L / → | Seek forward |
| M | Mute or unmute |
The remaining Vidstack MediaPlayer props pass through.
| Prop | Type | Default | Description |
|---|---|---|---|
| src | PlayerSrc | — | A direct audio file, or an object with an explicit MIME type. |
| title | string | — | Accessible name of the player and Media Session title. |
| playbackRates | readonly number[] | 0.5–2× | Rates listed in the options menu. |
| download | boolean | string | false | Shows download for the source, or uses an explicit download URL. |
| crossOrigin | MediaPlayerProps["crossOrigin"] | true | Set null to play cross-origin files that send no CORS headers. |
| load | "eager" | "idle" | "visible" | "play" | "custom" | "visible" | Controls when the media provider begins loading. |
| className | string | — | Classes applied to the player root. |
type AudioPlayerProps = Omit<
MediaPlayerProps,
"children" | "className" | "controls" | "src" | "title" | "viewType"
> & {
src: MediaPlayerProps["src"];
title: string;
playbackRates?: readonly number[];
download?: boolean | string;
className?: string;
};A player can normalize controls, but it cannot change what the browser can decode.
The file must be in a format the browser can decode, such as MP3, WAV, Ogg, or AAC in M4A. Cross-origin files need CORS headers unless crossOrigin is set to null, which plays them without letting scripts read the audio.
Adaptive streams are not configured by this component. Use the Video Player for HLS or DASH.
Vidstack source documentation