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…

"use client"

import * as React from "react"
import { Pause, Play } from "lucide-react"
import { cn } from "@/lib/utils"

export interface AudioPlayerProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Audio file URL. */
  src: string
  /** Optional label rendered above the seek bar (podcast episode, track name, sender…). */
  title?: string
  /** Native <audio> preload hint. */
  preload?: "none" | "metadata"
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/audio-player.json

Prompt

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, and duration all 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-gradient reading a single custom property (--ap-progress), so scrubbing repaints one inline style value instead of re-rendering two DOM layers.
  • Idle → playing → error state machineended returns the player to idle (not just paused-at-the-end), and a failed play() or a native error event both collapse into one disabled, message-only error state.
  • Optimistic seek — clicking or dragging the range updates local currentTime immediately rather than waiting for the next timeupdate tick, so the thumb never visibly lags the pointer.

On This Page