Wave Player

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

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

Installation

pnpm dlx shadcn@latest add @waves-cn/wave-player
npx shadcn@latest add @waves-cn/wave-player
yarn dlx shadcn@latest add @waves-cn/wave-player
bunx --bun shadcn@latest add @waves-cn/wave-player

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.

import {  useState,  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">;export type WavesurferProps = PartialWavesurferOptions &  OnWavesurferEvents & {    className?: string;  };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>;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;// ─── Component ───────────────────────────────────────────────────────────────const WavesurferPlayer = memo(  (props: WavesurferProps): ReactElement => {    const containerRef = useRef<HTMLDivElement | null>(null);    const wsRef = useRef<WaveSurfer | null>(null);    const { className, ...rest } = props;    // ── Separate options from event handlers    const options: Partial<WaveSurferOptions> = {};    const eventProps: OnWavesurferEvents = {};    for (const key in rest) {      if (isEventProp(key))        eventProps[key as keyof OnWavesurferEvents] = rest[          key as keyof typeof rest        ] as never;      else        options[key as keyof PartialWavesurferOptions] = rest[          key as keyof typeof rest        ] as never;    }    // ── Resolve CSS vars    const waveColor =      (options.waveColor as string | undefined) ??      WAVESURFER_DEFAULTS.waveColor;    const progressColor =      (options.progressColor as string | undefined) ??      WAVESURFER_DEFAULTS.progressColor;    const resolvedWaveColor = useCssVar(waveColor);    const resolvedProgressColor = useCssVar(progressColor);    // ── Keep event handlers in a ref — changes never cause re-subscription    const eventsRef = useRef(eventProps);    eventsRef.current = eventProps;    // ── Keep non-url options in a ref — changes applied imperatively    const optionsRef = useRef(options);    optionsRef.current = options;    // ── Create instance only when url or structural options change    const url = options.url as string | undefined;    const height =      (options.height as number | undefined) ?? WAVESURFER_DEFAULTS.height;    const barWidth =      (options.barWidth as number | undefined) ?? WAVESURFER_DEFAULTS.barWidth;    const barGap =      (options.barGap as number | undefined) ?? WAVESURFER_DEFAULTS.barGap;    const barRadius =      (options.barRadius as number | undefined) ??      WAVESURFER_DEFAULTS.barRadius;    const minPxPerSec =      (options.minPxPerSec as number | undefined) ??      WAVESURFER_DEFAULTS.minPxPerSec;    const cursorWidth =      (options.cursorWidth as number | undefined) ??      WAVESURFER_DEFAULTS.cursorWidth;    const dragToSeek = options.dragToSeek as boolean | undefined;    const media = options.media as HTMLMediaElement | undefined;    useEffect(() => {      if (!containerRef.current) return;      const ws = WaveSurfer.create({        ...WAVESURFER_DEFAULTS,        url,        height,        barWidth,        barGap,        barRadius,        minPxPerSec,        cursorWidth,        dragToSeek,        media,        plugins: optionsRef.current.plugins,        waveColor: resolvedWaveColor,        progressColor: resolvedProgressColor,        container: containerRef.current,      });      wsRef.current = ws;      // Subscribe to all events via ref — always calls latest handler      const eventEntries = Object.keys(eventsRef.current);      const unsubs = eventEntries.map((name) => {        const event = getEventName(name);        return ws.on(event, (...args) =>          (            eventsRef.current[              name as keyof OnWavesurferEvents            ] as WavesurferEventHandler<WaveSurferEvents[typeof event]>          )?.(ws, ...args),        );      });      return () => {        unsubs.forEach((fn) => fn());        ws.destroy();        wsRef.current = null;      };      // Only remount when these primitive options change — NOT handlers, NOT colors      // eslint-disable-next-line react-hooks/exhaustive-deps    }, [      url,      height,      barWidth,      barGap,      barRadius,      minPxPerSec,      cursorWidth,      dragToSeek,    ]);    // ── Apply color changes imperatively — zero remount on theme switch    useEffect(() => {      wsRef.current?.setOptions({        waveColor: resolvedWaveColor,        progressColor: resolvedProgressColor,      });    }, [resolvedWaveColor, resolvedProgressColor]);    // ── Skeleton    const [isReady, setIsReady] = useState(false);    useEffect(() => {      const ws = wsRef.current;      if (!ws) return;      // Sync immediately with current instance — avoids skeleton flash on re-render      // when the instance already exists and audio is already decoded      setIsReady(ws.getDuration() > 0);      const unsubs = [        ws.on("ready", () => setIsReady(true)),        ws.on("load", () => setIsReady(false)),        ws.on("destroy", () => setIsReady(false)),      ];      return () => unsubs.forEach((fn) => fn());      // Re-attach when instance changes (url change creates new instance)      // eslint-disable-next-line react-hooks/exhaustive-deps    }, [wsRef.current]);    return (      <div className={className} style={{ position: "relative" }}>        {!isReady && (          <div            style={{              height,              width: "100%",              position: "absolute",              inset: 0,              borderRadius: 4,              background: "hsl(var(--muted))",              animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",            }}          />        )}        <div ref={containerRef} style={!isReady ? { opacity: 0 } : undefined} />      </div>    );  },  (prev, next) => {    // Only remount when structural audio options change — ignore handlers and className    const STRUCTURAL_KEYS = [      "url",      "height",      "barWidth",      "barGap",      "barRadius",      "minPxPerSec",      "cursorWidth",      "dragToSeek",      "waveColor",      "progressColor",    ];    return STRUCTURAL_KEYS.every(      (k) =>        prev[k as keyof WavesurferProps] === next[k as keyof WavesurferProps],    );  },);export default WavesurferPlayer;// ─── Hook ────────────────────────────────────────────────────────────────────export function useWavesurfer({  container,  waveColor = WAVESURFER_DEFAULTS.waveColor,  progressColor = WAVESURFER_DEFAULTS.progressColor,  ...options}: Omit<WaveSurferOptions, "container"> & {  container: RefObject<HTMLDivElement | null>;}) {  const resolvedWaveColor = useCssVar(waveColor as string);  const resolvedProgressColor = useCssVar(progressColor as string);  const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);  const [isReady, setIsReady] = useState(false);  const [isPlaying, setIsPlaying] = useState(false);  const [currentTime, setCurrentTime] = useState(0);  const url = options.url as string | undefined;  const height =    (options.height as number | undefined) ?? WAVESURFER_DEFAULTS.height;  const barWidth =    (options.barWidth as number | undefined) ?? WAVESURFER_DEFAULTS.barWidth;  const barGap =    (options.barGap as number | undefined) ?? WAVESURFER_DEFAULTS.barGap;  const barRadius =    (options.barRadius as number | undefined) ?? WAVESURFER_DEFAULTS.barRadius;  const minPxPerSec =    (options.minPxPerSec as number | undefined) ??    WAVESURFER_DEFAULTS.minPxPerSec;  useEffect(() => {    if (!container.current) return;    const ws = WaveSurfer.create({      ...WAVESURFER_DEFAULTS,      ...options,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,      container: container.current,    });    setWavesurfer(ws);    const unsubs = [      ws.on("load", () => {        setIsReady(false);        setIsPlaying(false);        setCurrentTime(0);      }),      ws.on("ready", () => {        setIsReady(true);      }),      ws.on("play", () => {        setIsPlaying(true);      }),      ws.on("pause", () => {        setIsPlaying(false);      }),      ws.on("timeupdate", () => {        setCurrentTime(ws.getCurrentTime());      }),      ws.on("destroy", () => {        setIsReady(false);        setIsPlaying(false);      }),    ];    return () => {      unsubs.forEach((fn) => fn());      ws.destroy();    };    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [url, height, barWidth, barGap, barRadius, minPxPerSec]);  useEffect(() => {    wavesurfer?.setOptions({      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    });  }, [wavesurfer, resolvedWaveColor, resolvedProgressColor]);  return { wavesurfer, isReady, isPlaying, currentTime };}// ─── CSS var resolver ────────────────────────────────────────────────────────export function useCssVar(value: string): string {  const [resolved, setResolved] = useState(value);  useEffect(() => {    const match = value.match(/^var\((--[^)]+)\)$/);    if (!match) {      setResolved(value);      return;    }    const varName = match[1];    const resolve = () => {      const raw = getComputedStyle(document.documentElement)        .getPropertyValue(varName)        .trim();      const isHsl = /^[\d.]+ [\d.]+% [\d.]+%$/.test(raw);      setResolved(raw ? (isHsl ? `hsl(${raw})` : raw) : value);    };    resolve();    const observer = new MutationObserver(resolve);    observer.observe(document.documentElement, {      attributes: true,      attributeFilter: ["class", "style", "data-theme"],    });    return () => observer.disconnect();  }, [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;}function formatTime(t: number): string {  const m = Math.floor(t / 60);  const s = Math.floor(t % 60);  return `${m}:${s.toString().padStart(2, "0")}`;}export 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);  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],  );  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 wave 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 { WavePlayer } from "@/components/waves-cn/wave-player"
<WavePlayer src="/coastline.mp3" />

Examples

Minimal

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

With title

Loading...
import WavePlayer from "@/components/waves-cn/wave-player";export default function WavePlayerWithTitleDemo() {  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/waves-cn/wave-player";export default function WavePlayerCustomDemo() {  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 { WavePlayer } from "@/components/waves-cn/wave-player"

export function MyPlayer() {
  return (
    <WavePlayer
      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

WavePlayer

PropTypeDefaultDescription
srcstring(Required) Wave URL. Changing it destroys and reinitializes WaveSurfer.
titlestringTrack title displayed above the waveform.
defaultVolumenumber0.8Initial volume 01.
autoPlaybooleanfalseStart playing as soon as wave 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