Media
Audio Player
A minimal audio player card wrapping the native <audio> element — play/pause, seek, and mm:ss time, fully token-styled.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/audio-player.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AudioPlayer" component (lucide-react
Play/Pause icons) that wraps a native <audio> element.
Contract
- Export a forwardRef div extending HTMLAttributes<HTMLDivElement> (ref goes
to the container, not the <audio> node).
- Props: src (string, required), title? (string, shown above the seek bar),
preload? ("none" | "metadata", default "metadata").
Behavior
- Render a hidden native <audio src={src} preload={preload}> controlled via
an internal ref; all playback state is driven off its events, not managed
independently:
- onPlay / onPause → sync a local `playing` boolean (drives the button icon).
- onLoadedMetadata → read `duration`.
- onTimeUpdate → read `currentTime` (this is a legitimate setState-in-event-
handler, not an effect).
- onEnded → return to idle: `playing=false`, reset `currentTime` to 0 and
seek the audio element back to 0.
- onError → enter an error state; disable the play button and the seek
input, and replace the seek row with "Couldn't load audio".
- Play/pause button: toggles `audio.paused ? audio.play() : audio.pause()`;
a rejected play() promise (autoplay-blocked, decode failure) also sets the
error state.
- Seek bar is a controlled `<input type="range">`: min 0, max duration (0
while duration is unknown, and the input stays disabled until then), value
= currentTime, step 0.01. onChange seeks the audio element immediately and
updates local state optimistically (no waiting for the next timeupdate).
- Time label shows "current / duration" formatted mm:ss (tabular-nums).
- Cleanup: pause the <audio> element on unmount (imperative effect cleanup;
every other event is wired through React props, not manual
addEventListener/removeEventListener).
Rendering & styling
- Card: bg-card border rounded-xl px-4 py-3, flex items-center gap-3.
- Play/pause: a size-9 circular button, bg-primary text-primary-foreground,
focus-visible ring, disabled:opacity-50 when in the error state.
- Seek bar is a re-skinned native range input (appearance-none): track is
h-1.5 rounded-full styled via ::-webkit-slider-runnable-track /
::-moz-range-track with a linear-gradient background split at a CSS custom
property (e.g. --ap-progress) that the component updates via inline style
as `${(currentTime / duration) * 100}%` — played portion bg-primary,
remaining bg-muted. Thumb is a separate size-3.5 rounded-full bg-primary
via ::-webkit-slider-thumb / ::-moz-range-thumb (WebKit needs a negative
margin-top to center it against the thin track; Firefox centers it
automatically).
- Semantic tokens only throughout: bg-card, bg-primary/text-primary-foreground,
bg-muted, text-muted-foreground, text-destructive, focus-visible ring
tokens — no hardcoded colors, so the player adopts the host theme and dark
mode for free. Merge className via cn().
- aria: play/pause button gets aria-label "Play" / "Pause" that flips with
state; the range gets aria-label "Seek" and aria-valuetext set to
"mm:ss of mm:ss" so screen readers announce time, not raw seconds.
Customization levers
- Volume / playback rate: not built in — add a second range or a speed
dropdown that reads/writes audio.volume / audio.playbackRate the same way
the seek bar reads/writes currentTime.
- Waveform: swap the flat track for a wavesurfer.js canvas when you need
amplitude visualization instead of a plain progress rail — the play/pause
and time-sync logic carries over unchanged.
- Download affordance: add a ghost icon button wired to `<a href={src}
download>` next to the time label.
- Density: drop the title row and shrink px-4 py-3 to px-3 py-2 for a
compact voice-message-bubble variant (see the no-title demo instance).
- Compact width: the card has no fixed width — cap it with a wrapper
max-w-* to match a chat bubble or a card grid cell.Concepts
- Native element, derived state — no playback state is invented;
playing,currentTime, anddurationall mirror events fired by the real<audio>node, so the UI can never drift from what's actually playing. - Controlled range as seek bar — a native
<input type="range">doubles as both the progress display and the scrub control; dragging it seeks the audio directly instead of going through a separate gesture handler. - CSS-var-driven track fill — the played/unplayed split is painted by a
linear-gradientreading a single custom property (--ap-progress), so scrubbing repaints one inline style value instead of re-rendering two DOM layers. - Idle → playing → error state machine —
endedreturns the player to idle (not just paused-at-the-end), and a failedplay()or a nativeerrorevent both collapse into one disabled, message-only error state. - Optimistic seek — clicking or dragging the range updates local
currentTimeimmediately rather than waiting for the nexttimeupdatetick, so the thumb never visibly lags the pointer.