Skip to contentVibraUI
Media

Audio player

A tune with a waveform to scrub, its time, speed and mute, a one-line row variant for lists, and loading and unavailable states — with no request at all when there is no file.

An <audio> the browser plays, drawn as the kit draws it: Play named for the tune ("Play {title}"), the waveform from peaks — one bar per slice of loudness, the played ones filled in the accent and the rest in the field grey — which is also the slider that seeks it ("Seek {title}"), the time, a speed menu from 0.5× to 2× and a mute. Without peaks the waveform is a plain track. The arrows on the waveform move five seconds in reading order (in a right-to-left page it fills from the right and ArrowLeft goes forward), Home and End go to the ends, and the pointer seeks where it presses. One kit audio player plays at a time: starting one pauses whichever other is playing, on the same page. durationHint is printed until the file says its own length, so a list can show every row's length without loading every file — pair it with preload="none"; with neither, only the time played is shown. variant="row" puts Play, the waveform and the time on one line for a list, with no title line, speed or mute: the row's own text names it. A Loading audio spinner shows while the reader waits after pressing Play. A file it cannot play says "Audio unavailable" and why in place of the waveform — a dropped connection, or, since a bare src declares no type and a 404 looks like a format the browser cannot play, that the file isn't there or can't be played here — as an alert, and Play is held (still focusable, so the reason is read with it as its description); the focus on a control it drops moves to Play. With no src at all the player renders no media element and makes no request; it says unavailableReason ("Audio unavailable" unless you say otherwise) the same way, but quietly, since nothing failed, and shows durationHint if it has one. Where a row is too narrow for the whole reason, say a short one in unavailableReason and the whole one in unavailableDetail: the line beside Play stays one a row can hold, and Play reads the whole reason as its description. The time is a run of its own, left to right, so a right-to-left page keeps "0:05 / 0:16" in order. The shadcn CLI installs text only, so audio files are not installed with the player: put them in public/ and point src at them, and an install without them shows the unavailable state.

Install

npx shadcn@latest add @vibra/audio-player

Needs the @vibra registry in your components.json — set it up once.

Examples

One line in a list

Props

PropTypeDefaultDescription
srcstring—The file. Without one, no media element and no request; the player says unavailableReason.
titlestring—What is playing; names the player, its Play button and its waveform.
peaksnumber[]—Loudness 0–1, one bar each; without it the waveform is a plain track.
variant"default" | "row""default"row fits Play, the waveform and the time on one line of a list.
preload"none" | "metadata""metadata"none fetches nothing until Play — for a long list, with durationHint.
durationHintnumber—Seconds, shown until the file says its own length.
onPlay() => void—Fires when playback starts.
unavailableReasonstring"Audio unavailable"Said beside Play, and as its description, when there is no src.
unavailableDetailstring—The whole reason, when unavailableReason is a short form of it: read as Play's description in its place, and never drawn as a second line.

Dependencies

Source

components/ui/audio-player/index.tsx
"use client"

import * as React from "react"
import { PauseIcon, PlayIcon, Volume2Icon, VolumeXIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { useMediaElement, type MediaState } from "@/hooks/use-media-element"
import { formatMediaTime, SPEEDS } from "@/lib/media-seek"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Spinner } from "@/components/ui/spinner"

import { Waveform } from "./waveform"

export type AudioPlayerProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The file. Without one the player makes no request at all and says why it cannot play. */
  src?: string
  /** What is playing; the controls are named for it ("Play {title}"). */
  title: string
  /** Loudness, 0–1, one bar each across the waveform; without them it is a plain track. */
  peaks?: number[]
  variant?: "default" | "row"
  preload?: "none" | "metadata"
  /** Seconds, shown until the file says its own length — and all along when there is no file. */
  durationHint?: number
  onPlay?: () => void
  /** Why it cannot play when there is no src, said beside Play and as its description. */
  unavailableReason?: string
  /**
   * The whole reason, when `unavailableReason` is a short form of it: read as
   * Play's description in its place, so a line a narrow row can hold in full
   * loses nothing.
   */
  unavailableDetail?: string
}

const REASONS: Record<NonNullable<MediaState["error"]>, string> = {
  missing: "the file isn't there",
  unsupported: "this browser can't play its format",
  // A bare src declares no type, so a 404 and an unplayable format look alike.
  unplayable: "the file isn't there, or its format can't be played here",
  network: "the connection dropped",
  aborted: "loading was stopped",
}

/**
 * A tune with a waveform to scrub, its time, a speed menu and a mute; `row`
 * fits it on one line of a list. One kit audio player plays at a time.
 */
function AudioPlayer({
  src,
  title,
  peaks,
  variant = "default",
  preload = "metadata",
  durationHint,
  onPlay,
  unavailableReason = "Audio unavailable",
  unavailableDetail,
  className,
  onFocus,
  onBlur,
  ...props
}: AudioPlayerProps) {
  const audioRef = React.useRef<HTMLAudioElement>(null)
  const playRef = React.useRef<HTMLButtonElement>(null)
  const media = useMediaElement(audioRef)
  const reasonId = React.useId()
  const detailId = React.useId()
  // A file that failed says why itself; without a file, the whole reason is read when there is one.
  const detailed = !src && Boolean(unavailableDetail)

  // One kit audio player at a time: another one starting pauses this one.
  // Media events do not bubble, so the document hears them as they pass down.
  React.useEffect(() => {
    const onAnyPlay = (event: Event) => {
      const own = audioRef.current
      const other = event.target
      if (own && other !== own && other instanceof HTMLAudioElement && other.dataset.slot === "audio-player-media") own.pause()
    }
    document.addEventListener("play", onAnyPlay, true)
    return () => document.removeEventListener("play", onAnyPlay, true)
  }, [])

  const unavailable = !src || media.status === "error"

  // A player that fails drops its waveform, its speed menu and Mute, and Play
  // stays: when the focus was on one of those, it moves to Play rather than
  // falling to the page.
  const [focusOnControls, setFocusOnControls] = React.useState(false)
  const wasUnavailable = React.useRef(unavailable)
  React.useLayoutEffect(() => {
    if (wasUnavailable.current === unavailable) return
    wasUnavailable.current = unavailable
    if (unavailable && focusOnControls) playRef.current?.focus()
  }, [unavailable, focusOnControls])
  const reason = src ? `Audio unavailable: ${REASONS[media.error ?? "unsupported"]}.` : unavailableReason
  const duration = media.duration || durationHint || 0
  const playing = !media.paused && !unavailable
  const waiting = playing && media.status === "loading"
  const started = playing || media.currentTime > 0
  const row = variant === "row"
  const time = unavailable
    ? duration > 0
      ? formatMediaTime(duration)
      : ""
    : row
      ? formatMediaTime(started || duration === 0 ? media.currentTime : duration)
      : duration > 0
        ? `${formatMediaTime(media.currentTime)} / ${formatMediaTime(duration)}`
        : formatMediaTime(media.currentTime)

  const play = (
    <Button
      ref={playRef}
      size={row ? "icon-sm" : "icon"}
      variant={row ? "outline" : "default"}
      className="shrink-0 rounded-full"
      aria-label={`${playing ? "Pause" : "Play"} ${title}`}
      aria-describedby={unavailable ? (detailed ? detailId : reasonId) : undefined}
      // Held rather than removed, and still focusable, so the reason is
      // read with it.
      disabled={unavailable}
      focusableWhenDisabled
      onClick={media.toggle}
    >
      {playing ? <PauseIcon aria-hidden="true" /> : <PlayIcon aria-hidden="true" className="ms-px" />}
    </Button>
  )

  // flex-1 only across a row: down the default variant's column it would be
  // a basis of nothing, and the bars are heights of it.
  const track = unavailable ? (
    // An alert when the file failed; a player that never had one says so quietly.
    <p id={reasonId} role={src ? "alert" : undefined} className={cn("min-w-0 truncate text-xs text-muted-foreground", row && "flex-1")}>
      {reason}
    </p>
  ) : (
    <Waveform peaks={peaks} currentTime={media.currentTime} duration={duration} onSeek={media.seek} label={`Seek ${title}`} className={cn(row && "flex-1")} />
  )

  const clock = (
    <span className="flex shrink-0 items-center justify-end gap-1.5 text-xs whitespace-nowrap text-muted-foreground tabular-nums">
      {waiting ? <Spinner aria-label="Loading audio" className="size-3" /> : null}
      {/* A run of its own, left to right: a right-to-left page would turn "0:05 / 0:16" round. */}
      {time ? <bdi dir="ltr">{time}</bdi> : null}
    </span>
  )

  return (
    <div
      role="group"
      aria-label={title}
      data-slot="audio-player"
      data-variant={variant}
      data-status={src ? media.status : "error"}
      onFocus={(event) => {
        onFocus?.(event)
        setFocusOnControls((event.target as Element) !== playRef.current)
      }}
      onBlur={(event) => {
        onBlur?.(event)
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusOnControls(false)
      }}
      className={cn(row ? "flex min-w-0 items-center gap-2" : "panel flex min-w-0 items-center gap-3 p-3", className)}
      {...props}
    >
      {/* No src, no element: an empty <audio> would still be a request to make. */}
      {src ? <audio ref={audioRef} data-slot="audio-player-media" src={src} preload={preload} onPlay={onPlay} /> : null}
      {/* Read as Play's description, never as a second line on the row. */}
      {detailed ? (
        <span id={detailId} hidden>
          {unavailableDetail}
        </span>
      ) : null}
      {play}
      {row ? (
        <>
          {track}
          <span className={cn("w-10 shrink-0", !time && "hidden")}>{clock}</span>
        </>
      ) : (
        <>
          <div className="flex min-w-0 flex-1 flex-col gap-1">
            <div className="flex items-baseline justify-between gap-3">
              <p className="truncate text-sm font-medium">{title}</p>
              {clock}
            </div>
            {track}
          </div>
          {unavailable ? null : (
            <div className="flex shrink-0 items-center gap-0.5">
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={
                    <Button variant="ghost" size="sm" aria-label={`Speed: ${media.playbackRate}×`} className="min-w-11 tabular-nums">
                      {media.playbackRate}×
                    </Button>
                  }
                />
                <DropdownMenuContent align="end" className="w-auto min-w-24">
                  <DropdownMenuRadioGroup value={String(media.playbackRate)} onValueChange={(value) => media.setPlaybackRate(Number(value))}>
                    {SPEEDS.map((speed) => (
                      <DropdownMenuRadioItem key={speed} value={String(speed)} className="tabular-nums">
                        {speed}×
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuContent>
              </DropdownMenu>
              <Button variant="ghost" size="icon-sm" aria-label={media.muted ? "Unmute" : "Mute"} onClick={() => media.setMuted(!media.muted)}>
                {media.muted ? <VolumeXIcon aria-hidden="true" /> : <Volume2Icon aria-hidden="true" />}
              </Button>
            </div>
          )}
        </>
      )}
    </div>
  )
}

export { AudioPlayer }
components/ui/audio-player/waveform.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { seekKeyTarget, seekValue, useSeekPointer } from "@/lib/media-seek"

export type WaveformProps = {
  /** Loudness, 0–1, one bar each; without them the bar is a plain track. */
  peaks?: number[]
  currentTime: number
  duration: number
  onSeek: (seconds: number) => void
  label: string
  className?: string
}

const unit = (value: number) => Math.min(1, Math.max(0, value))

/**
 * The tune drawn as its loudness, and the slider that seeks it: the bars
 * already played are filled in the accent, the rest stay in the field grey.
 * Its keys and its pointer are the kit's shared seek bar's (media-seek), so
 * it runs in reading order and a right-to-left page plays from the right.
 */
function Waveform({ peaks, currentTime, duration, onSeek, label, className }: WaveformProps) {
  const seek = useSeekPointer({ duration, onSeek })
  const played = duration > 0 ? unit(currentTime / duration) : 0

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const to = seekKeyTarget(event, currentTime, duration)
    if (to === undefined) return
    event.preventDefault()
    onSeek(to)
  }

  return (
    <div
      {...seekValue(currentTime, duration)}
      aria-label={label}
      tabIndex={0}
      data-slot="audio-player-waveform"
      data-dragging={seek.dragging || undefined}
      onKeyDown={onKeyDown}
      {...seek.handlers}
      className={cn("relative flex h-8 min-w-0 cursor-pointer touch-none items-center rounded-sm focus-ring", className)}
    >
      {peaks?.length ? (
        <div className="flex size-full items-center gap-px">
          {peaks.map((peak, index) => (
            <span
              key={index}
              data-slot="audio-player-bar"
              data-played={index / peaks.length < played || undefined}
              className="min-w-px flex-1 rounded-full bg-input data-played:bg-brand"
              style={{ height: `${Math.max(0.12, unit(peak)) * 100}%` }}
            />
          ))}
        </div>
      ) : (
        <div data-slot="audio-player-track" className="relative h-1 w-full overflow-hidden rounded-full bg-input">
          <div className="absolute inset-y-0 start-0 bg-brand" style={{ width: `${played * 100}%` }} />
        </div>
      )}
    </div>
  )
}

export { Waveform }