Audio Player
A floating capsule transport (Apple-Music mini-player) on the acrylic frosted surface.
A frosted capsule "now playing" bar. Two groups: an always-on transport (prev / play-pause / next, plus any right-side tools you pass) and a now-playing info group (cover + title + artist + a bottom seek rail) that appears only while a track is loaded. Fully controlled — wire the callbacks to your own audio engine. The optional volume control reveals a vertical Slider on hover.
Installation
npx shadcn add https://acrylic-ui.vercel.app/r/audio-player.jsonUsage
The right-side tools are yours to pass via actions. In this example the queue button opens an
"up next" Sheet from the right — a plain inline list, just to show the
pattern; the player itself prescribes nothing about it.
"use client"import * as React from "react"import { ListMusic, MessageSquareText, Play } from "lucide-react"import { AudioPlayer } from "@/registry/acrylic/audio-player"import { Sheet, SheetContent, SheetTrigger } from "@/registry/acrylic/sheet"const DURATION = 215 // 3:35const TOOL = "flex size-9 shrink-0 items-center justify-center rounded-full text-foreground/70 transition-colors hover:bg-[var(--acr-hover)] hover:text-foreground"// A simple "up next" list — just enough to show the queue Sheet pattern.const QUEUE = [ { id: "1", title: "Fantasma", artist: "Tainy, Jhayco", duration: 215 }, { id: "2", title: "Mojabi Ghost", artist: "Bad Bunny", duration: 234 }, { id: "3", title: "La Jumpa", artist: "Arcángel, Bad Bunny", duration: 207 }, { id: "4", title: "Coco Chanel", artist: "Eladio Carrión", duration: 191 }, { id: "5", title: "TQG", artist: "Karol G, Shakira", duration: 200 }, { id: "6", title: "Las Mujeres Ya No Lloran", artist: "Shakira", duration: 226 },]const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`// A controlled Audio Player: local state drives play/seek/volume, a 1s tick advances the// elapsed time while playing. The queue button (右下角) opens an "up next" Sheet from the right —// just a usage example; the list is plain inline JSX, not a separate component.export default function AudioPlayerDemo() { const [playing, setPlaying] = React.useState(false) const [time, setTime] = React.useState(42) const [volume, setVolume] = React.useState(0.7) const [currentId, setCurrentId] = React.useState("1") React.useEffect(() => { if (!playing) return const id = setInterval(() => setTime((t) => (t + 1) % DURATION), 1000) return () => clearInterval(id) }, [playing]) return ( <div className="flex w-full justify-center px-2 py-6"> <AudioPlayer className="max-w-xl" track={{ title: "Fantasma", artist: "Tainy, Jhayco", cover: "https://avatar.vercel.sh/fantasma.png" }} playing={playing} currentTime={time} duration={DURATION} volume={volume} hasPrev hasNext onToggle={() => setPlaying((p) => !p)} onPrev={() => setTime(0)} onNext={() => setTime(0)} onSeek={setTime} onVolumeChange={setVolume} actions={ <> <button aria-label="Lyrics" className={TOOL}> <MessageSquareText className="size-4" /> </button> {/* queue → opens an "up next" Sheet from the right */} <Sheet> <SheetTrigger asChild> <button aria-label="Up next" className={TOOL}> <ListMusic className="size-4" /> </button> </SheetTrigger> <SheetContent className="gap-0 p-0"> <div className="flex items-baseline gap-1.5 px-4 pt-3 pb-2 text-foreground"> <h2 className="text-lg font-bold tracking-tight">Up next</h2> <span className="text-xs text-muted-foreground">{QUEUE.length}</span> </div> <div className="scrollbar-mac min-h-0 flex-1 overflow-y-auto px-2 pb-2"> {QUEUE.map((t) => { const isCurrent = t.id === currentId return ( <button key={t.id} onClick={() => setCurrentId(t.id)} className="group flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left transition-colors hover:bg-[var(--acr-hover)]" > <div className="relative size-11 shrink-0 overflow-hidden rounded-lg"> <img src={`https://avatar.vercel.sh/${t.id}.png`} alt="" className="size-full object-cover" /> <div className={`absolute inset-0 flex items-center justify-center bg-black/35 transition-opacity ${isCurrent ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`} > <Play className="size-4 translate-x-px fill-white text-white" /> </div> </div> <div className="min-w-0 flex-1 text-foreground"> <div className={`truncate text-sm font-medium ${isCurrent ? "text-primary" : ""}`}>{t.title}</div> <div className="truncate text-xs text-muted-foreground">{t.artist}</div> </div> <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{clock(t.duration)}</span> </button> ) })} </div> </SheetContent> </Sheet> </> } /> </div> )}import { AudioPlayer } from "@/components/acrylic/audio-player"
<AudioPlayer
track={{ title, artist, cover }}
playing={playing}
currentTime={time}
duration={duration}
volume={volume}
hasPrev
hasNext
onToggle={toggle}
onPrev={prev}
onNext={next}
onSeek={seek}
onVolumeChange={setVolume}
/>Pass track={null} (or omit it) for the idle state — the transport stays visible and the
info group collapses. Drop the volume/onVolumeChange pair to hide the volume control.
Localization
Every user-facing string — the button aria-labels a screen reader announces, and the
visible stand-in for an empty track.title — lives in one flat AudioPlayerLabels
dictionary and defaults to English. Pass a partial labels to translate; it is merged
over the defaults, so you override only the keys you translate:
<AudioPlayer
labels={{ previous: "上一首", play: "播放", pause: "暂停", next: "下一首", volume: "音量" }}
…
/>| key | default | where it shows |
|---|---|---|
previous | "Previous track" | prev button aria-label |
play / pause | "Play" / "Pause" | play-pause button aria-label (by state) |
next | "Next track" | next button aria-label |
volume | "Volume" | volume button + its slider aria-label |
nowPlaying | "Now playing" | visible text when track.title is empty |
openNowPlaying | "Open now playing: {title}" | mini only — the cover/title button's aria-label; {title} is substituted |
DEFAULT_AUDIO_PLAYER_LABELS is exported if you want to read or spread the defaults.
Nothing user-facing is hard-coded in the markup — a string you can't reach through labels
is a bug, so report it rather than patching your vendored copy.
Mini variant
variant="mini" renders a compact chip for a sidebar footer or any tight rail — cover +
title/artist + play/pause with the seek rail along the bottom, and no prev/next/volume/actions.
It renders nothing when idle (so it takes no space); onOpen (cover/title click) is where
you navigate to the full player. When the rail is too narrow for it (e.g. an icon-only sidebar),
hide it — a cover-only stub isn't a usable player.
"use client"import * as React from "react"import { AudioPlayer } from "@/registry/acrylic/audio-player"const DURATION = 215 // 3:35// The `mini` variant — a compact chip for a sidebar/rail. Local state drives play/seek;// a 1s tick advances the elapsed time while playing. `onOpen` (cover/title click) would navigate// to the full player in a real app.export default function AudioPlayerMiniDemo() { const [playing, setPlaying] = React.useState(true) const [time, setTime] = React.useState(42) React.useEffect(() => { if (!playing) return const id = setInterval(() => setTime((t) => (t + 1) % DURATION), 1000) return () => clearInterval(id) }, [playing]) return ( <div className="flex w-full justify-center px-2 py-8"> <div className="w-[220px]"> <AudioPlayer variant="mini" track={{ title: "Monaco (feat. someone with a very long name)", artist: "Bad Bunny, Tainy, Jhayco & friends", cover: "https://avatar.vercel.sh/fantasma.png" }} playing={playing} currentTime={time} duration={DURATION} onToggle={() => setPlaying((p) => !p)} onSeek={setTime} onOpen={() => {}} /> </div> </div> )}<AudioPlayer
variant="mini"
track={{ title, artist, cover }}
playing={playing}
currentTime={time}
duration={duration}
onToggle={toggle}
onSeek={seek}
onOpen={goToPlayer}
/>For a persistent mini player, keep one global player (a single <audio> element) and provide
its state above your whole layout — sidebar and content both — so the chip can read it from
any page.
Full-screen stage
AudioPlayerStage is the immersive, full-screen face of the same player — an Apple-Music
"now playing" view on a flowing Silk shader background. It shares AudioPlayer's controlled
API (its track extends AudioPlayerTrack) and adds time-synced karaoke lyrics, a
cover-derived background color that crossfades between tracks, and a real Fullscreen toggle. It
installs separately, so the lightweight bar/mini transport never has to pull in the WebGL shader.
npx shadcn add https://acrylic-ui.vercel.app/r/audio-player-stage.jsonThe preview renders two triggers — Now Playing opens the stage windowed, Full screen
opens it straight into the Fullscreen API (via autoFullscreen). Inside: ▾ or Escape to close,
⤢ to toggle fullscreen, prev/next to switch tracks and watch the background recolor.
"use client"import * as React from "react"import { ListMusic, Maximize2 } from "lucide-react"import { Button } from "@/registry/acrylic/button"import { AudioPlayerStage, type AudioPlayerStageTrack } from "@/registry/acrylic/audio-player-stage"type Demo = AudioPlayerStageTrack & { a: string; b: string; duration: number }const TRACKS: Demo[] = [ { title: "Monaco (feat. a guest with a very long name)", artist: "Bad Bunny, Tainy & friends", a: "#22c55e", b: "#2563eb", duration: 215, lyrics: [ { time: 0, text: "The lights are still on, the corner wind slows" }, { time: 12, text: "Everything you said keeps circling back" }, { time: 26, text: "Monaco — the night unrolls like silk" }, { time: 41, text: "We fold the hours into this melody" }, { time: 58, text: "Neon shatters into stars on the water" }, { time: 76, text: "Here comes the chorus, fly with the heartbeat" }, { time: 95, text: "Don't stop now, let this moment stretch" }, { time: 118, text: "The sea breeze carried all the doubt away" }, { time: 140, text: "Monaco, I still remember your smile" }, { time: 165, text: "The lights will die, but this song won't" }, { time: 190, text: "Slowly, back to where it all began" }, ], }, { title: "Midnight Neon", artist: "Aurora Sky", a: "#ec4899", b: "#8b5cf6", duration: 198, lyrics: [ { time: 0, text: "The city hasn't fallen asleep yet" }, { time: 14, text: "Neon stretches every shadow long" }, { time: 30, text: "I walk the empty streets in time" }, { time: 47, text: "Your voice is still here in my headphones" }, { time: 66, text: "A pink and purple sky is flickering" }, { time: 88, text: "This second belongs to just us two" }, { time: 112, text: "Sing it once more, don't let it end" }, { time: 138, text: "The neon burns until the morning" }, { time: 168, text: "And I'm still standing here, waiting" }, ], }, { title: "Golden Hour", artist: "JVKE", a: "#f59e0b", b: "#f43f5e", duration: 190, lyrics: [ { time: 0, text: "It was just a Sunday afternoon" }, { time: 15, text: "The light hit different in the room" }, { time: 32, text: "You turned to me and everything glowed" }, { time: 52, text: "Golden hour, don't let it go" }, { time: 74, text: "Time stood still, we didn't say a word" }, { time: 98, text: "Every second felt like it was gold" }, { time: 124, text: "Stay a little longer, hold me close" }, { time: 152, text: "The sun goes down but you still glow" }, ], }, { // no-lyrics fallback — instrumental; the lyrics pane is hidden and the player centers. title: "Weightless (Instrumental)", artist: "Marconi Union", a: "#0ea5e9", b: "#14b8a6", duration: 240, lyrics: [], },]// covers drawn on a canvas → same-origin data URIs, so color extraction isn't CORS-blockedfunction makeCover(a: string, b: string): string { if (typeof document === "undefined") return "" const c = document.createElement("canvas") c.width = 256 c.height = 256 const x = c.getContext("2d")! const g = x.createLinearGradient(0, 0, 256, 256) g.addColorStop(0, a) g.addColorStop(1, b) x.fillStyle = g x.fillRect(0, 0, 256, 256) const r = x.createRadialGradient(80, 64, 8, 80, 64, 200) r.addColorStop(0, "rgba(255,255,255,0.28)") r.addColorStop(1, "rgba(255,255,255,0)") x.fillStyle = r x.fillRect(0, 0, 256, 256) return c.toDataURL()}export default function AudioPlayerStageDemo() { const [open, setOpen] = React.useState(false) const [fullscreen, setFullscreen] = React.useState(false) const [ti, setTi] = React.useState(0) const [playing, setPlaying] = React.useState(true) const [time, setTime] = React.useState(0) const [volume, setVolume] = React.useState(0.7) const [covers, setCovers] = React.useState<string[]>([]) React.useEffect(() => { setCovers(TRACKS.map((t) => makeCover(t.a, t.b))) }, []) const t = TRACKS[ti] const track: AudioPlayerStageTrack = { title: t.title, artist: t.artist, cover: covers[ti], lyrics: t.lyrics, } const prev = () => { setTi((i) => (i - 1 + TRACKS.length) % TRACKS.length); setTime(0) } const next = () => { setTi((i) => (i + 1) % TRACKS.length); setTime(0) } return ( <div className="flex w-full flex-wrap items-center justify-center gap-3 px-2 py-10"> <Button onClick={() => { setFullscreen(false); setOpen(true) }}> <ListMusic /> Now Playing </Button> <Button variant="neutral" onClick={() => { setFullscreen(true); setOpen(true) }}> <Maximize2 /> Full screen </Button> {open && ( <AudioPlayerStage track={track} playing={playing} currentTime={time} duration={t.duration} volume={volume} autoFullscreen={fullscreen} extractFromCover colorTransitionMs={400} onToggle={() => setPlaying((p) => !p)} onPrev={prev} onNext={next} onSeek={setTime} onVolumeChange={setVolume} onClose={() => setOpen(false)} /> )} </div> )}import { AudioPlayerStage } from "@/components/acrylic/audio-player-stage"
<AudioPlayerStage
track={{
title,
artist,
cover,
lyrics: [ { time: 0, text: "…" }, { time: 12, text: "…" } ], // omit → no-lyrics layout
}}
playing={playing}
currentTime={time} // seconds; the stage runs its own smooth clock for karaoke
duration={duration}
volume={volume}
extractFromCover // pull the background color from the cover art…
// accentColor="#5E3AA8" // …or set it explicitly (skips extraction)
colorTransitionMs={400}
onToggle={toggle}
onPrev={prev}
onNext={next}
onSeek={seek}
onVolumeChange={setVolume}
onClose={close}
/>- Lyrics are LRC-style
{ time, text }lines; the active line fills bright, karaoke-style, as playback crosses it. Omitlyrics(or pass[]) and the lyrics pane disappears — the player centers instead. - Background color comes from the cover when
extractFromCoveris set (needs a same-origin or CORS-enabled cover; otherwise it falls back toaccentColor, then a default) and crossfades overcolorTransitionMs. - Fullscreen: the ⤢ button calls the Fullscreen API on the stage (browsers require a real user click).
Stage-only props
| prop | type | default | description |
|---|---|---|---|
track | AudioPlayerStageTrack | — | AudioPlayerTrack + optional lyrics: { time, text }[] |
accentColor | string | — | force the background color (skips cover extraction) |
extractFromCover | boolean | true | pull the background color from the cover art |
colorTransitionMs | number | 400 | crossfade duration between track colors (0 = snap) |
nowPlayingLabel | string | — | optional eyebrow caption above the cover (e.g. "FROM YOUR LIBRARY"); off when unset |
labels | Partial<AudioPlayerStageLabels> | English | override the stage's own UI copy — see below |
onClose | () => void | — | the collapse (▾) button / Escape |
The stage has its own label dictionary, AudioPlayerStageLabels (defaults exported as
DEFAULT_AUDIO_PLAYER_STAGE_LABELS), merged the same way as AudioPlayer's:
collapse, enterFullscreen / exitFullscreen, previous, play / pause, next,
progress, volume, and unknownTrack (the visible stand-in for an empty
track.title).
labels and nowPlayingLabel are not interchangeable, and deliberately can't set the same
string: labels is the component's own UI copy — fixed strings that vary only by locale —
while nowPlayingLabel is caller content that varies per instance. That's why a missing
title falls back to labels.unknownTrack and never to nowPlayingLabel: an eyebrow caption
rendered in the title slot reads as if it were the track's name.
The playback props (playing, currentTime, duration, volume, hasPrev/hasNext,
onToggle/onPrev/onNext/onSeek/onVolumeChange) match AudioPlayer above.
Props
| prop | type | default | description |
|---|---|---|---|
variant | "bar" | "mini" | "bar" | full transport bar, or a compact sidebar/rail chip |
onOpen | () => void | — | mini only — cover/title clicked (open the full player) |
track | AudioPlayerTrack | null | — | { title, artist?, cover? }; null/omitted = idle (bar: info hidden; mini: renders nothing) |
playing | boolean | false | play/pause icon state |
currentTime | number | 0 | elapsed seconds (time readout + seek fill) |
duration | number | 0 | total seconds |
volume | number | — | 0..1; pass with onVolumeChange to show the hover volume slider |
hasPrev / hasNext | boolean | false | enable the prev / next buttons |
onToggle | () => void | — | play/pause clicked |
onPrev / onNext | () => void | — | skip clicked |
onSeek | (seconds: number) => void | — | seek rail clicked |
onVolumeChange | (volume: number) => void | — | volume slider dragged (0..1) |
actions | ReactNode | — | extra tool buttons at the right end of the transport |
labels | Partial<AudioPlayerLabels> | English | override the built-in strings — see Localization |
Plus all native <div> props.