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-playernpx shadcn@latest add @waves-cn/wave-playeryarn dlx shadcn@latest add @waves-cn/wave-playerbunx --bun shadcn@latest add @waves-cn/wave-playerInstall the peer dependency.
npm install wavesurfer.jsInstall the required shadcn primitives.
npx shadcn@latest add card button slider skeletonCopy 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
Sliderbelow 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
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | (Required) Wave URL. Changing it destroys and reinitializes WaveSurfer. |
title | string | — | Track title displayed above the waveform. |
defaultVolume | number | 0.8 | Initial volume 0–1. |
autoPlay | boolean | false | Start playing as soon as wave is ready. |
onPlay | () => void | — | Fired when playback starts. |
onPause | () => void | — | Fired when playback pauses. |
onFinish | () => void | — | Fired when the track ends. |
onTimeUpdate | (currentTime: number, duration: number) => void | — | Fired each tick with current time and total duration. |
waveHeight | number | 64 | Waveform canvas height in px. |
barWidth | number | 3 | Waveform bar width in px. |
barGap | number | 2 | Gap between bars in px. |
barRadius | number | 2 | Border radius of each bar. |
minPxPerSec | number | 1 | Minimum pixels per second (zoom level). |
className | string | — | Extra classes on the outer <Card>. |