Wave Player

A shadcn-styled waveform audio player built on wavesurfer.js.

Loading...
import WavePlayer from "@/components/wave-player";export default function AudioPlayerMinimalDemo() {  return (    <div className="w-full  mx-auto p-6">      <WavePlayer src="/coastline.mp3" />    </div>  );}

Installation

Install the required shadcn/ui primitives.

npx shadcn@latest add card button slider skeleton

Add the component via jsrepo.

npx jsrepo add @waves-cn/ui/wave-player

This will automatically install wave-cn (the core hook) and wavesurfer.js + lucide-react.

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

npx shadcn@latest add card button slider skeleton

Copy wave-cn.tsx into your project.

/** * A React component for wavesurfer.js * * Usage: * * import WavesurferPlayer from '@wavesurfer/react' * * <WavesurferPlayer *   url="/my-server/audio.ogg" *   waveColor="purple" *   height={100} *   onReady={(wavesurfer) => console.log('Ready!', wavesurfer)} * /> */import {  useState,  useMemo,  useEffect,  useRef,  memo,  type ReactElement,  type RefObject,} from "react";import WaveSurfer, {  type WaveSurferEvents,  type WaveSurferOptions,} from "wavesurfer.js";type WavesurferEventHandler<T extends unknown[]> = (  wavesurfer: WaveSurfer,  ...args: T) => void;type OnWavesurferEvents = {  [K in keyof WaveSurferEvents as `on${Capitalize<K>}`]?: WavesurferEventHandler<    WaveSurferEvents[K]  >;};type PartialWavesurferOptions = Omit<WaveSurferOptions, "container">;/** * Props for the Wavesurfer component * @public */export type WavesurferProps = PartialWavesurferOptions &  OnWavesurferEvents & {    className?: string;  };/** * Shared waveform defaults applied to every useWavesurfer instance. * Override any of these by passing the corresponding option explicitly. */export const WAVESURFER_DEFAULTS = {  waveColor: "var(--muted-foreground)",  progressColor: "var(--primary)",  height: 64,  barWidth: 3,  barGap: 2,  barRadius: 2,  minPxPerSec: 1,  cursorWidth: 0,} as const satisfies Partial<WaveSurferOptions>;/** * Use wavesurfer instance */function useWavesurferInstance(  containerRef: RefObject<HTMLDivElement | null>,  options: Partial<WaveSurferOptions>,): WaveSurfer | null {  const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);  // Flatten options object to an array of keys and values to compare them deeply in the hook deps  // Exclude plugins from deep comparison since they are mutated during initialization  const optionsWithoutPlugins = useMemo(() => {    // eslint-disable-next-line @typescript-eslint/no-unused-vars    const { plugins, ...rest } = options;    return rest;  }, [options]);  const flatOptions = useMemo(    () => Object.entries(optionsWithoutPlugins).flat(),    [optionsWithoutPlugins],  );  // Create a wavesurfer instance  useEffect(() => {    if (!containerRef?.current) return;    const ws = WaveSurfer.create({      ...optionsWithoutPlugins,      plugins: options.plugins,      container: containerRef.current,    });    setWavesurfer(ws);    return () => {      ws.destroy();    };    // Only recreate if plugins array reference changes (not on mutation)    // Users should memoize the plugins array to prevent unnecessary re-creation  }, [containerRef, options.plugins, ...flatOptions]);  return wavesurfer;}/** * Use wavesurfer state */function useWavesurferState(wavesurfer: WaveSurfer | null): {  isReady: boolean;  isPlaying: boolean;  currentTime: number;} {  const [isReady, setIsReady] = useState<boolean>(false);  const [isPlaying, setIsPlaying] = useState<boolean>(false);  const [hasFinished, setHasFinished] = useState<boolean>(false);  const [currentTime, setCurrentTime] = useState<number>(0);  useEffect(() => {    if (!wavesurfer) return;    const unsubscribeFns = [      wavesurfer.on("load", () => {        setIsReady(false);        setIsPlaying(false);        setCurrentTime(0);      }),      wavesurfer.on("ready", () => {        setIsReady(true);        setIsPlaying(false);        setHasFinished(false);        setCurrentTime(0);      }),      wavesurfer.on("finish", () => {        setHasFinished(true);      }),      wavesurfer.on("play", () => {        setIsPlaying(true);      }),      wavesurfer.on("pause", () => {        setIsPlaying(false);      }),      wavesurfer.on("timeupdate", () => {        setCurrentTime(wavesurfer.getCurrentTime());      }),      wavesurfer.on("destroy", () => {        setIsReady(false);        setIsPlaying(false);      }),    ];    return () => {      unsubscribeFns.forEach((fn) => fn());    };  }, [wavesurfer]);  return useMemo(    () => ({      isReady,      isPlaying,      hasFinished,      currentTime,    }),    [isPlaying, hasFinished, currentTime, isReady],  );}const EVENT_PROP_RE = /^on([A-Z])/;const isEventProp = (key: string) => EVENT_PROP_RE.test(key);const getEventName = (key: string) =>  key.replace(EVENT_PROP_RE, (_, $1) =>    $1.toLowerCase(),  ) as keyof WaveSurferEvents;/** * Parse props into wavesurfer options and events */function useWavesurferProps(  props: WavesurferProps,): [PartialWavesurferOptions, OnWavesurferEvents] {  // Props starting with `on` are wavesurfer events, e.g. `onReady`  // The rest of the props are wavesurfer options  return useMemo<[PartialWavesurferOptions, OnWavesurferEvents]>(() => {    const allOptions = { ...props };    const allEvents = { ...props };    for (const key in allOptions) {      if (isEventProp(key)) {        delete allOptions[key as keyof WavesurferProps];      } else {        delete allEvents[key as keyof WavesurferProps];      }    }    return [allOptions, allEvents];  }, [props]);}/** * Subscribe to wavesurfer events */function useWavesurferEvents(  wavesurfer: WaveSurfer | null,  events: OnWavesurferEvents,) {  const flatEvents = useMemo(() => Object.entries(events).flat(), [events]);  // Subscribe to events  useEffect(() => {    if (!wavesurfer) return;    const eventEntries = Object.entries(events);    if (!eventEntries.length) return;    const unsubscribeFns = eventEntries.map(([name, handler]) => {      const event = getEventName(name);      return wavesurfer.on(event, (...args) =>        (handler as WavesurferEventHandler<WaveSurferEvents[typeof event]>)(          wavesurfer,          ...args,        ),      );    });    return () => {      unsubscribeFns.forEach((fn) => fn());    };  }, [wavesurfer, ...flatEvents]);}/** * Wavesurfer player component * @see https://wavesurfer.xyz/docs/modules/wavesurfer * @public */const WavesurferPlayer = memo((props: WavesurferProps): ReactElement => {  const containerRef = useRef<HTMLDivElement | null>(null);  const { className, ...propsWithoutClassName } = props;  const [rawOptions, events] = useWavesurferProps(propsWithoutClassName);  // Apply WAVESURFER_DEFAULTS and resolve CSS var() tokens — same logic as  // useWavesurfer — so WavesurferPlayer benefits from shared defaults too.  const waveColor =    (rawOptions.waveColor as string | undefined) ??    WAVESURFER_DEFAULTS.waveColor;  const progressColor =    (rawOptions.progressColor as string | undefined) ??    WAVESURFER_DEFAULTS.progressColor;  const resolvedWaveColor = useCssVar(waveColor);  const resolvedProgressColor = useCssVar(progressColor);  const options = useMemo(() => {    const { waveColor: _wc, progressColor: _pc, ...rest } = rawOptions;    const cleanOptions = Object.fromEntries(      Object.entries(rest).filter(([, v]) => v !== undefined),    ) as PartialWavesurferOptions;    return {      ...WAVESURFER_DEFAULTS,      ...cleanOptions,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    } as PartialWavesurferOptions;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(rawOptions)]);  const wavesurfer = useWavesurferInstance(containerRef, options);  useWavesurferEvents(wavesurfer, events);  // Create a container div  return <div ref={containerRef} className={className} />;});/** * @public */export default WavesurferPlayer;/** * React hook for wavesurfer.js * * Automatically applies shared defaults (colors, height, bar shape, volume). * Any option passed explicitly will override the defaults. * CSS var() tokens in waveColor and progressColor are resolved at runtime * so they work correctly with the Canvas API. * * ``` * import { useWavesurfer } from '@/components/cors/wavesurfer-player' * * const App = () => { *   const containerRef = useRef<HTMLDivElement | null>(null) * *   const { wavesurfer, isReady, isPlaying, currentTime } = useWavesurfer({ *     container: containerRef, *     url: '/my-server/audio.ogg', *   }) * *   return <div ref={containerRef} /> * } * ``` * * @public */export function useWavesurfer({  container,  waveColor = WAVESURFER_DEFAULTS.waveColor,  progressColor = WAVESURFER_DEFAULTS.progressColor,  ...options}: Omit<WaveSurferOptions, "container"> & {  container: RefObject<HTMLDivElement | null>;}): ReturnType<typeof useWavesurferState> & {  wavesurfer: ReturnType<typeof useWavesurferInstance>;} {  // Resolve CSS var() tokens so the Canvas API receives actual color values  const resolvedWaveColor = useCssVar(waveColor as string);  const resolvedProgressColor = useCssVar(progressColor as string);  // Memoize mergedOptions so useWavesurferInstance gets a stable object reference.  // Without this, a new object is created every render → flatOptions changes →  // the instance is destroyed and recreated on every render, wiping spread props.  // Also strip undefined values so they don't silently override WAVESURFER_DEFAULTS.  const mergedOptions = useMemo(() => {    const cleanOptions = Object.fromEntries(      Object.entries(options).filter(([, v]) => v !== undefined),    ) as Partial<WaveSurferOptions>;    return {      ...WAVESURFER_DEFAULTS,      ...cleanOptions,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    } as Partial<WaveSurferOptions>;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(options)]);  const wavesurfer = useWavesurferInstance(container, mergedOptions);  const state = useWavesurferState(wavesurfer);  return useMemo(() => ({ ...state, wavesurfer }), [state, wavesurfer]);}export function useCssVar(value: string): string {  const [resolved, setResolved] = useState(value);  useEffect(() => {    // Only resolve if the value looks like a CSS variable    const match = value.match(/^var\((--[^)]+)\)$/);    if (!match) {      setResolved(value);      return;    }    const varName = match[1];    const raw = getComputedStyle(document.documentElement)      .getPropertyValue(varName)      .trim();    // shadcn stores values as bare HSL channels e.g. "222.2 47.4% 11.2%"    // Canvas needs a full color string — wrap in hsl() if needed    const isHslChannels = /^[\d.]+ [\d.]+% [\d.]+%$/.test(raw);    setResolved(raw ? (isHslChannels ? `hsl(${raw})` : raw) : value);  }, [value]);  return resolved;}

Copy wave-player.tsx into your project.

import * as React from "react";import { cn } from "@/lib/utils";import { Card, CardContent } from "@/components/ui/card";import { Button } from "@/components/ui/button";import { Slider } from "@/components/ui/slider";import {  Play,  Pause,  Volume2,  VolumeX,  Loader2,  RotateCcw,} from "lucide-react";import WavesurferPlayer from "@/lib/wave-cn";import type WaveSurfer from "wavesurfer.js";export interface WavePlayerProps {  /** Audio source URL */  src: string;  /** Optional title shown above the waveform */  title?: string;  /** Initial volume (0–1) */  defaultVolume?: number;  /** Audio bar color. Accepts any CSS value including var(--*) tokens @default "var(--muted-foreground)" */  waveColor?: string;  /** Progress bar color. Accepts any CSS value including var(--*) tokens @default "var(--primary)" */  progressColor?: string;  /** Waveform bar width in px @default 3 */  barWidth?: number;  /** Waveform bar gap in px @default 2 */  barGap?: number;  /** Rounded borders for bars @default 2 */  barRadius?: number;  /** Waveform height in px @default 64 */  waveHeight?: number;  /** Minimum pixels per second (zoom level) @default 1 */  minPxPerSec?: number;  /** Autoplay on mount */  autoPlay?: boolean;  /** Called when playback starts */  onPlay?: () => void;  /** Called when playback pauses */  onPause?: () => void;  /** Called when playback finishes */  onFinish?: () => void;  /** Called with current time on every audio process tick */  onTimeUpdate?: (currentTime: number, duration: number) => void;  className?: string;}// ─── Helpersfunction formatTime(t: number): string {  const m = Math.floor(t / 60);  const s = Math.floor(t % 60);  return `${m}:${s.toString().padStart(2, "0")}`;}// ─── Componentexport function WavePlayer({  src,  title,  defaultVolume = 0.8,  waveColor,  progressColor,  barWidth,  barGap,  barRadius,  waveHeight,  minPxPerSec,  autoPlay = false,  onPlay,  onPause,  onFinish,  onTimeUpdate,  className,}: WavePlayerProps) {  const wavesurferRef = React.useRef<WaveSurfer | null>(null);  const [isReady, setIsReady] = React.useState(false);  const [isPlaying, setIsPlaying] = React.useState(false);  const [volume, setVolume] = React.useState(defaultVolume);  const [isMuted, setIsMuted] = React.useState(false);  const [duration, setDuration] = React.useState(0);  const [currentTime, setCurrentTime] = React.useState(0);  // ── Controls  const togglePlay = React.useCallback(    () => wavesurferRef.current?.playPause(),    [],  );  const restart = React.useCallback(() => {    if (!wavesurferRef.current || !isReady) return;    wavesurferRef.current.setTime(0);    wavesurferRef.current.play();  }, [isReady]);  const handleVolume = React.useCallback((v: number[]) => {    const value = v[0];    setVolume(value);    setIsMuted(value === 0);    wavesurferRef.current?.setVolume(value);  }, []);  const toggleMute = React.useCallback(() => {    if (!wavesurferRef.current) return;    const next = !isMuted;    setIsMuted(next);    wavesurferRef.current.setVolume(next ? 0 : volume);  }, [isMuted, volume]);  const handleSeek = React.useCallback(    ([v]: number[]) => {      if (!wavesurferRef.current || !isReady) return;      wavesurferRef.current.seekTo(v);    },    [isReady],  );  // ── WavesurferPlayer event handlers  const handleReady = React.useCallback(    (ws: WaveSurfer) => {      wavesurferRef.current = ws;      ws.setVolume(defaultVolume);      if (autoPlay) ws.play();      setDuration(ws.getDuration());      setIsReady(true);    },    [defaultVolume, autoPlay],  );  const handlePlay = React.useCallback(() => {    setIsPlaying(true);    onPlay?.();  }, [onPlay]);  const handlePause = React.useCallback(() => {    setIsPlaying(false);    onPause?.();  }, [onPause]);  const handleFinish = React.useCallback(    (ws: WaveSurfer) => {      setIsPlaying(false);      onFinish?.();    },    [onFinish],  );  const handleTimeupdate = React.useCallback(    (ws: WaveSurfer) => {      const t = ws.getCurrentTime();      setCurrentTime(t);      onTimeUpdate?.(t, ws.getDuration());    },    [onTimeUpdate],  );  const handleSeeking = React.useCallback((ws: WaveSurfer) => {    setCurrentTime(ws.getCurrentTime());  }, []);  const handleDestroy = React.useCallback(() => {    wavesurferRef.current = null;    setIsReady(false);    setIsPlaying(false);    setCurrentTime(0);    setDuration(0);  }, []);  // ── Derived  const progress = duration > 0 ? currentTime / duration : 0;  // ── Render  return (    <Card      className={cn(        "w-full px-0 border-0 rounded-none bg-transparent",        className,      )}    >      <CardContent className=" border-0 px-0 space-y-3">        {title && (          <p className="text-sm font-medium text-foreground truncate">            {title}          </p>        )}        <div className="relative w-full rounded-sm overflow-hidden bg-muted/40">          {!isReady && (            <div              className="absolute inset-0 z-10 flex items-center justify-center bg-card/80 backdrop-blur-[2px]"              style={{ height: waveHeight ?? 64 }}            >              <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />            </div>          )}          <WavesurferPlayer            url={src}            waveColor={waveColor}            progressColor={progressColor}            height={waveHeight}            barWidth={barWidth}            barGap={barGap}            barRadius={barRadius}            minPxPerSec={minPxPerSec}            dragToSeek            onReady={handleReady}            onPlay={handlePlay}            onPause={handlePause}            onFinish={handleFinish}            onTimeupdate={handleTimeupdate}            onSeeking={handleSeeking}            onDestroy={handleDestroy}          />        </div>        <div className="flex items-center gap-2">          <span className="text-[11px] tabular-nums text-muted-foreground w-10 text-right shrink-0">            {formatTime(currentTime)}          </span>          <Slider            className="flex-1"            value={[progress]}            min={0}            max={1}            step={0.001}            disabled={!isReady}            onValueChange={handleSeek}          />          <span className="text-[11px] tabular-nums text-muted-foreground w-10 shrink-0">            {formatTime(duration)}          </span>        </div>        <div className="flex items-center justify-between gap-3">          <div className="flex items-center gap-1.5">            <Button              size="icon"              variant="ghost"              className="h-8 w-8 text-muted-foreground hover:text-foreground"              disabled={!isReady}              onClick={restart}              aria-label="Restart"            >              <RotateCcw size={15} />            </Button>            <Button              size="icon"              variant="secondary"              className="h-9 w-9"              disabled={!isReady}              onClick={togglePlay}              aria-label={isPlaying ? "Pause" : "Play"}            >              {isPlaying ? <Pause size={17} /> : <Play size={17} />}            </Button>          </div>          <div className="flex items-center gap-2 w-36">            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              onClick={toggleMute}              aria-label={isMuted ? "Unmute" : "Mute"}            >              {isMuted ? <VolumeX size={15} /> : <Volume2 size={15} />}            </Button>            <Slider              value={[isMuted ? 0 : volume]}              min={0}              max={1}              step={0.01}              onValueChange={handleVolume}              aria-label="Volume"            />          </div>        </div>      </CardContent>    </Card>  );}export default WavePlayer;

Update the import paths to match your project setup.

Features

  • Waveform visualization — renders a decoded waveform on a Canvas via wavesurfer.js; click or drag anywhere on it to seek.
  • Seek bar — a shadcn Slider below the waveform gives precise scrubbing independently of the waveform.
  • Volume & mute — slider-based volume control with a mute toggle that preserves the last set level.
  • Restart — jumps back to 0 and resumes playback instantly via ws.setTime(0).
  • Loading state — a spinner overlays the waveform while the audio is being fetched and decoded.
  • Shadcn theming — colors are resolved at runtime from Tailwind utility classes so the component inherits your active theme, including dark mode, without any extra configuration.

Usage

import { AudioPlayer } from "@/components/ui/wave-player"
<AudioPlayer src="/coastline.mp3" />

Examples

Minimal

Loading...
import WavePlayer from "@/components/wave-player";export default function AudioPlayerMinimalDemo() {  return (    <div className="w-full  mx-auto p-6">      <WavePlayer src="/coastline.mp3" />    </div>  );}

With title

Loading...
import WavePlayer from "@/components/wave-player";export default function AudioPlayerWithTitleDemo() {  return (    <div className="w-full  mx-auto p-6">      <WavePlayer src="/coastline.mp3" title="Coastline" />    </div>  );}

Custom waveform

barWidth, barGap, barRadius, and waveHeight let you fully control the shape of the waveform bars.

Loading...
import WavePlayer from "@/components/wave-player";export default function AudioPlayerCustomDemo() {  return (    <div className="w-full  mx-auto p-6">      <WavePlayer        src="/coastline.mp3"        title="Custom Waveform Style"        waveHeight={80}        barWidth={4}        barGap={3}        barRadius={4}        defaultVolume={0.6}      />    </div>  );}

With event callbacks

Because onPlay, onPause, onFinish, and onTimeUpdate are functions, they must be passed from a Client Component.

"use client"

import { AudioPlayer } from "@/components/ui/wave-player"

export function MyPlayer() {
  return (
    <AudioPlayer
      src="/coastline.mp3"
      onPlay={()  => console.log("Playing")}
      onPause={() => console.log("Paused")}
      onFinish={() => console.log("Finished")}
      onTimeUpdate={(current, total) =>
        console.log(`${current.toFixed(1)}s / ${total.toFixed(1)}s`)
      }
    />
  )
}

API Reference

AudioPlayer

PropTypeDefaultDescription
srcstring(Required) Audio URL. Changing it destroys and reinitializes WaveSurfer.
titlestringTrack title displayed above the waveform.
defaultVolumenumber0.8Initial volume 01.
autoPlaybooleanfalseStart playing as soon as audio is ready.
onPlay() => voidFired when playback starts.
onPause() => voidFired when playback pauses.
onFinish() => voidFired when the track ends.
onTimeUpdate(currentTime: number, duration: number) => voidFired each tick with current time and total duration.
waveHeightnumber64Waveform canvas height in px.
barWidthnumber3Waveform bar width in px.
barGapnumber2Gap between bars in px.
barRadiusnumber2Border radius of each bar.
minPxPerSecnumber1Minimum pixels per second (zoom level).
classNamestringExtra classes on the outer <Card>.

On this page