Audio Player
Full and mini music players with seek, volume, playlists, and auto-advance, plus a hook to build your own.
betaAudioPlayer.tsx
Examples
Full player with playlist
These demo tracks are generated in your browser, so there are no audio files to download.
Track 1 of 3
Hard Edges
stream/ui demo
0:000:23
audio-player/full.tsx
"use client";
import { AudioPlayer } from "@streamuilib/react";
import { useDemoTracks } from "@/lib/audio/demo-tracks";
export default function AudioPlayerFull() {
const tracks = useDemoTracks();
return <AudioPlayer tracks={tracks} playlist />;
}Mini player
Hard Edges
stream/ui demo
audio-player/mini.tsx
"use client";
import { MiniPlayer } from "@streamuilib/react";
import { useDemoTracks } from "@/lib/audio/demo-tracks";
export default function AudioPlayerMini() {
const tracks = useDemoTracks();
return <MiniPlayer tracks={tracks} />;
}Build your own with useAudioPlayer
The hook handles playback; you choose the layout.
Offset Shadow0:19
audio-player/headless.tsx
"use client";
import { Pause, Play, SkipForward } from "lucide-react";
import { Button, Slider, formatTime, useAudioPlayer } from "@streamuilib/react";
import { useDemoTracks } from "@/lib/audio/demo-tracks";
/** Your own layout on top of useAudioPlayer: here, a tape-deck strip. */
export default function AudioPlayerHeadless() {
const tracks = useDemoTracks();
const p = useAudioPlayer(tracks, { defaultIndex: 1 });
return (
<div className="flex w-full max-w-sm items-center gap-3 border-2 border-ink bg-pink-soft p-3 shadow-brutal">
<audio {...p.audioProps} />
<Button size="icon" variant="pink" className="size-11" aria-label={p.playing ? "Pause" : "Play"} disabled={!p.ready} onClick={p.toggle}>
{p.playing ? <Pause aria-hidden /> : <Play aria-hidden />}
</Button>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex justify-between gap-2 font-mono text-xs">
<span className="truncate font-bold">{p.track?.title}</span>
<span className="tabular-nums">{formatTime(p.duration - p.time)}</span>
</div>
<Slider
aria-label="Seek"
aria-valuetext={`${formatTime(p.time)} of ${formatTime(p.duration)}`}
value={p.time}
max={p.duration || 1}
step="any"
tone="pink"
disabled={!p.ready}
onValueChange={p.seek}
/>
</div>
<Button size="icon" variant="secondary" className="size-11" aria-label="Next track" onClick={p.next}>
<SkipForward aria-hidden />
</Button>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { AudioPlayer } from "@streamuilib/react";
<AudioPlayer
playlist
tracks={[
{ title: "Hard Edges", artist: "stream/ui", src: "/audio/hard-edges.mp3" },
{ title: "Ink & Paper", artist: "stream/ui", src: "/audio/ink-and-paper.mp3" },
]}
/>API
| Prop | Type | Default | Description |
|---|---|---|---|
| tracks | Track[] | — | { title, src, artist?, cover?, tone?, duration? }. Required. |
| playlist | boolean | false | AudioPlayer only: show the track list. |
| defaultIndex | number | 0 | Track selected first. |
| defaultVolume | number | 0.8 | Start volume, 0 to 1. |
| loop | boolean | true | Wrap from the last track to the first. |
| className | string | — | Classes for the player frame. |
| Prop | Type | Default | Description |
|---|---|---|---|
| returns | { track, index, playing, time, duration, volume, muted, ready, … } | — | Live playback state. |
| controls | play, pause, toggle, seek, next, previous, select, setVolume, toggleMute | — | Actions; previous restarts the track if you're past 3 seconds. |
| audioProps | { ref, src, preload } | — | Spread onto your own <audio> element. |
Accessibility
- Each player is a labelled region; every button has a name (Play/Pause, Previous track, Next track, Mute/Unmute).
- The seek and volume sliders announce readable values like "0:12 of 0:23" and "80%".
- Mute is a toggle button with aria-pressed, and the now-playing track is announced politely.
- State follows the <audio> element's own events, so it stays correct when the browser or system pauses playback.
Source
packages/react/src/AudioPlayer.tsx
"use client";
import { Disc3, LoaderCircle, Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
import { cn } from "./cn";
import { Button } from "./Button";
import { Equalizer } from "./Equalizer";
import { Slider } from "./Slider";
import { TrackList } from "./TrackList";
import { formatTime, type Track } from "./music";
import { toneBg } from "./tones";
import { useAudioPlayer, type UseAudioPlayerOptions } from "./useAudioPlayer";
/** Cover art, or a toned tile with a disc when there isn't any. */
export function TrackCover({ track, className }: { track?: Track; className?: string }) {
if (track?.cover) {
return (
// A cover can come from any host the app chooses, so it's a plain <img>.
// eslint-disable-next-line @next/next/no-img-element
<img
src={track.cover}
alt=""
className={cn("aspect-square shrink-0 border-2 border-ink object-cover shadow-brutal-sm", className)}
/>
);
}
return (
<span
aria-hidden
className={cn(
"grid aspect-square shrink-0 place-items-center border-2 border-ink text-on-bright shadow-brutal-sm",
toneBg[track?.tone ?? "lime"],
className,
)}
>
<Disc3 className="size-1/2" strokeWidth={1.75} />
</span>
);
}
export type AudioPlayerProps = UseAudioPlayerOptions & {
tracks: Track[];
/** Show the track list under the controls. */
playlist?: boolean;
className?: string;
};
/**
* A full player: cover, title, previous / play / next, seek with times,
* volume with mute, and an optional playlist. Built on useAudioPlayer.
*/
export function AudioPlayer({ tracks, playlist = false, className, ...options }: AudioPlayerProps) {
const p = useAudioPlayer(tracks, options);
const title = p.track?.title ?? "No track";
return (
<section
aria-label={`Audio player: ${title}`}
className={cn("flex w-full max-w-md flex-col border-2 border-ink bg-paper-raised text-ink shadow-brutal", className)}
>
<audio {...p.audioProps} />
<div className="flex items-center gap-4 border-b-2 border-ink p-4">
<TrackCover track={p.track} className="size-20 sm:size-24" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="font-mono text-xs text-ink-muted uppercase">
Track {p.index + 1} of {tracks.length}
</p>
<p className="truncate text-lg leading-tight font-bold">{title}</p>
{p.track?.artist && <p className="truncate text-sm text-ink-muted">{p.track.artist}</p>}
<Equalizer playing={p.playing} bars={5} className="mt-2" tone={p.track?.tone ?? "lime"} />
</div>
</div>
<div className="flex flex-col gap-2 p-4">
<Slider
aria-label="Seek"
aria-valuetext={`${formatTime(p.time)} of ${formatTime(p.duration)}`}
value={p.time}
max={p.duration || 1}
step="any"
disabled={!p.ready}
onValueChange={p.seek}
tone={p.track?.tone ?? "lime"}
/>
<div className="flex justify-between font-mono text-xs text-ink-muted tabular-nums">
<span>{formatTime(p.time)}</span>
<span>{formatTime(p.duration)}</span>
</div>
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button size="icon" variant="secondary" className="size-10" aria-label="Previous track" onClick={p.previous}>
<SkipBack aria-hidden />
</Button>
<Button
size="icon"
className="size-12"
aria-label={p.playing ? "Pause" : "Play"}
disabled={!p.ready}
onClick={p.toggle}
>
{p.buffering && p.playing ? (
<LoaderCircle aria-hidden className="motion-safe:animate-spin" />
) : p.playing ? (
<Pause aria-hidden />
) : (
<Play aria-hidden />
)}
</Button>
<Button size="icon" variant="secondary" className="size-10" aria-label="Next track" onClick={p.next}>
<SkipForward aria-hidden />
</Button>
</div>
<div className="flex w-36 items-center gap-2">
<Button
size="icon"
variant="secondary"
className="size-9 shrink-0"
aria-label={p.muted ? "Unmute" : "Mute"}
aria-pressed={p.muted}
onClick={p.toggleMute}
>
{p.muted || p.volume === 0 ? <VolumeX aria-hidden /> : <Volume2 aria-hidden />}
</Button>
<Slider
aria-label="Volume"
aria-valuetext={`${Math.round((p.muted ? 0 : p.volume) * 100)}%`}
value={p.muted ? 0 : p.volume}
max={1}
step={0.05}
onValueChange={p.setVolume}
tone="blue"
/>
</div>
</div>
</div>
{playlist && (
<TrackList
tracks={tracks}
index={p.index}
playing={p.playing}
onSelect={p.select}
className="border-t-2 border-ink"
/>
)}
<p aria-live="polite" className="sr-only">
{p.playing ? `Playing ${title}${p.track?.artist ? ` by ${p.track.artist}` : ""}` : ""}
</p>
</section>
);
}
export type MiniPlayerProps = UseAudioPlayerOptions & {
tracks: Track[];
className?: string;
};
/** A compact bar: cover, title, play/pause, next, and a thin seek line. */
export function MiniPlayer({ tracks, className, ...options }: MiniPlayerProps) {
const p = useAudioPlayer(tracks, options);
const title = p.track?.title ?? "No track";
return (
<section
aria-label={`Mini player: ${title}`}
className={cn("flex w-full max-w-md flex-col border-2 border-ink bg-paper-raised text-ink shadow-brutal", className)}
>
<audio {...p.audioProps} />
<div className="flex items-center gap-3 p-3">
<TrackCover track={p.track} className="size-12" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate font-bold">{title}</p>
{p.track?.artist && <p className="truncate text-xs text-ink-muted">{p.track.artist}</p>}
</div>
<Equalizer playing={p.playing} bars={3} className="hidden sm:inline-flex" tone={p.track?.tone ?? "lime"} />
<Button size="icon" className="size-10" aria-label={p.playing ? "Pause" : "Play"} disabled={!p.ready} onClick={p.toggle}>
{p.playing ? <Pause aria-hidden /> : <Play aria-hidden />}
</Button>
<Button size="icon" variant="secondary" className="size-10" aria-label="Next track" onClick={p.next}>
<SkipForward aria-hidden />
</Button>
</div>
<Slider
aria-label="Seek"
aria-valuetext={`${formatTime(p.time)} of ${formatTime(p.duration)}`}
value={p.time}
max={p.duration || 1}
step="any"
disabled={!p.ready}
onValueChange={p.seek}
tone={p.track?.tone ?? "lime"}
className="h-7 border-t-2 border-ink px-3"
/>
<p aria-live="polite" className="sr-only">
{p.playing ? `Playing ${title}` : ""}
</p>
</section>
);
}