Wave Timeline
A waveform player with timeline markers built on wavesurfer.js.
Loading...
import WaveTimeline from "@/components/waves-cn/wave-timeline";export default function WaveTimelineDemo() { return ( <div className="w-full mx-auto p-6"> <WaveTimeline src="/coastline.mp3" title="SoundHelix — Song 1" /> </div> );}Installation
pnpm dlx shadcn@latest add @waves-cn/wave-timelinenpx shadcn@latest add @waves-cn/wave-timelineyarn dlx shadcn@latest add @waves-cn/wave-timelinebunx --bun shadcn@latest add @waves-cn/wave-timelineInstall the peer dependency.
npm install wavesurfer.jsInstall the required shadcn primitives.
npx shadcn@latest add button slider card 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-timeline.tsx into your project.
import * as React from "react";import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline.esm.js";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, ZoomIn, ZoomOut,} from "lucide-react";import WavesurferPlayer from "@/lib/wave-cn";import type WaveSurfer from "wavesurfer.js";// ─── Typesexport interface TimelineOptions { height?: number; timeInterval?: number; primaryLabelInterval?: number; secondaryLabelInterval?: number; fontSize?: string;}export interface WaveTimelineProps { src: string; title?: string; defaultVolume?: number; waveColor?: string; progressColor?: string; barWidth?: number; barGap?: number; barRadius?: number; waveHeight?: number; defaultZoom?: number; minZoom?: number; maxZoom?: number; topTimeline?: TimelineOptions | false; bottomTimeline?: TimelineOptions | false; onPlay?: () => void; onPause?: () => void; onFinish?: () => void; 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 WaveTimeline({ src, title, defaultVolume = 0.8, waveColor, progressColor, barWidth = 2, barGap = 1, barRadius = 2, waveHeight = 80, defaultZoom = 50, minZoom = 10, maxZoom = 500, topTimeline = {}, bottomTimeline = false, onPlay, onPause, onFinish, onTimeUpdate, className,}: WaveTimelineProps) { 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 [zoom, setZoom] = React.useState(defaultZoom); // ── Memoized plugins ────────────────────────────────────────────────────── // Stable array reference — WavesurferPlayer uses reference equality to decide // whether to recreate the instance, so this must not change on every render. const plugins = React.useMemo(() => { if (typeof window === "undefined") return []; const list: InstanceType<typeof TimelinePlugin>[] = []; if (topTimeline !== false) { list.push( TimelinePlugin.create({ height: topTimeline.height ?? 20, insertPosition: "beforebegin", timeInterval: topTimeline.timeInterval ?? 0.5, primaryLabelInterval: topTimeline.primaryLabelInterval ?? 5, secondaryLabelInterval: topTimeline.secondaryLabelInterval ?? 1, style: { fontSize: topTimeline.fontSize ?? "11px", color: "var(--muted-foreground)", background: "var(--muted)", }, }), ); } if (bottomTimeline !== false) { list.push( TimelinePlugin.create({ height: bottomTimeline.height ?? 14, timeInterval: bottomTimeline.timeInterval ?? 0.1, primaryLabelInterval: bottomTimeline.primaryLabelInterval ?? 1, secondaryLabelInterval: bottomTimeline.secondaryLabelInterval, style: { fontSize: bottomTimeline.fontSize ?? "10px", color: "var(--muted-foreground)", background: "var(--muted)", }, }), ); } return list; }, [JSON.stringify(topTimeline), JSON.stringify(bottomTimeline)]); // ── Event handlers const handleReady = React.useCallback( (ws: WaveSurfer) => { wavesurferRef.current = ws; ws.setVolume(defaultVolume); setDuration(ws.getDuration()); setIsReady(true); }, [defaultVolume], ); 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); }, []); // ── Zoom ────────────────────────────────────────────────────────────────── const handleZoom = React.useCallback((v: number[]) => { const value = v[0]; setZoom(value); wavesurferRef.current?.zoom(value); }, []); const zoomIn = React.useCallback(() => { const next = Math.min(zoom * 1.5, maxZoom); setZoom(next); wavesurferRef.current?.zoom(next); }, [zoom, maxZoom]); const zoomOut = React.useCallback(() => { const next = Math.max(zoom / 1.5, minZoom); setZoom(next); wavesurferRef.current?.zoom(next); }, [zoom, minZoom]); // ── Playback ────────────────────────────────────────────────────────────── 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], ); // ── Derived ─────────────────────────────────────────────────────────────── const progress = duration > 0 ? currentTime / duration : 0; // ── Render ──────────────────────────────────────────────────────────────── return ( <Card className={cn( "w-full px-0 border-none rounded-none bg-transparent", className, )} > <CardContent className=" space-y-3 p-0"> {/* Title */} {title && ( <p className="text-sm font-medium text-foreground truncate"> {title} </p> )} {/* Waveform + timeline */} <div className="relative w-full rounded-sm overflow-hidden border border-border"> {!isReady && ( <div className="absolute inset-0 z-10 flex items-center justify-center bg-card/80 backdrop-blur-[2px]" style={{ minHeight: waveHeight }} > <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={defaultZoom} fillParent dragToSeek hideScrollbar={false} plugins={plugins} onReady={handleReady} onPlay={handlePlay} onPause={handlePause} onFinish={handleFinish} onTimeupdate={handleTimeupdate} onSeeking={handleSeeking} onDestroy={handleDestroy} /> </div> {/* Seek bar */} <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> {/* Controls */} <div className="flex items-center justify-between gap-3 flex-wrap"> {/* Playback */} <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> {/* Zoom */} <div className="flex items-center gap-2 flex-1 max-w-50"> <Button size="icon" variant="ghost" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground" disabled={!isReady || zoom <= minZoom} onClick={zoomOut} aria-label="Zoom out" > <ZoomOut size={15} /> </Button> <Slider value={[zoom]} min={minZoom} max={maxZoom} step={1} disabled={!isReady} onValueChange={handleZoom} aria-label="Zoom" /> <Button size="icon" variant="ghost" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground" disabled={!isReady || zoom >= maxZoom} onClick={zoomIn} aria-label="Zoom in" > <ZoomIn size={15} /> </Button> </div> {/* Volume */} <div className="flex items-center gap-2 w-32"> <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 WaveTimeline;Update the import paths to match your project setup.
Features
- Timeline ruler — tick marks and time labels rendered above and/or below the waveform via the WaveSurfer Timeline plugin; fully themed via
useCssVar. - Zoom — three-way zoom control: step buttons (×1.5) and a precise slider, all calling
ws.zoom()at runtime. - Seek bar — a shadcn
Slidersynced to playback progress; click or drag the waveform directly thanks todragToSeek. - Volume & mute — slider-based volume with a mute toggle that preserves the last set level.
- Loading state — a spinner overlays the waveform while wave is being fetched and decoded.
- Shadcn theming —
waveColorandprogressColoraccept any CSS value includingvar(--*)tokens, resolved at runtime for the Canvas API viauseCssVar.
Usage
import { WaveTimeline } from "@/components/waves-cn/wave-timeline"<WaveTimeline url="/coastline.mp3" />Examples
With dual timelines
Show both a top and bottom timeline ruler.
Loading...
import WaveTimeline from "@/components/waves-cn/wave-timeline";export default function WaveTimelineDualDemo() { return ( <div className="w-full mx-auto p-6"> <WaveTimeline src="/coastline.mp3" title="Dual Timeline" topTimeline={{ height: 20, primaryLabelInterval: 5, timeInterval: 0.5 }} bottomTimeline={{ height: 14, primaryLabelInterval: 1, timeInterval: 0.1, }} /> </div> );}No ruler
Pass topTimeline={false} to render the waveform and zoom controls without any timeline.
Loading...
import WaveTimeline from "@/components/waves-cn/wave-timeline";export default function WaveTimelineNoRulerExample() { return ( <div className="w-full mx-auto p-6"> <WaveTimeline src="/coastline.mp3" title="No Ruler" topTimeline={false} /> </div> );}Custom zoom range
<WaveTimeline
url="/coastline.mp3"
defaultZoom={100}
minZoom={20}
maxZoom={1000}
/>API Reference
WaveTimeline
| Prop | Type | Default | Description |
|---|---|---|---|
url | string | — | (Required) Wave file URL to load. |
waveColor | string | var(--muted-foreground) | Wave bar color. Accepts any CSS value including var(--*) tokens. |
progressColor | string | var(--primary) | Progress bar color. Accepts any CSS value including var(--*) tokens. |
waveHeight | number | 80 | Wave canvas height in px. |
barWidth | number | 2 | Bar width in px. |
barGap | number | 1 | Gap between bars in px. |
barRadius | number | 2 | Bar border radius in px. |
defaultZoom | number | 50 | Initial zoom level in pixels per second. |
minZoom | number | 10 | Minimum zoom level. |
maxZoom | number | 500 | Maximum zoom level. |
topTimeline | TimelineOptions | false | {} | Top timeline config. Pass false to hide. |
bottomTimeline | TimelineOptions | false | false | Bottom timeline config. Pass {} or options to show. |
className | string | — | Root element class. |
style | CSSProperties | — | Root element inline style. |
TimelineOptions
| Option | Type | Default (top) | Default (bottom) | Description |
|---|---|---|---|---|
height | number | 20 | 14 | Height of the timeline bar in px. |
timeInterval | number | 0.5 | 0.1 | Seconds between each tick mark. |
primaryLabelInterval | number | 5 | 1 | Seconds between labeled ticks. |
secondaryLabelInterval | number | 1 | undefined | Seconds between secondary labeled ticks. |
fontSize | string | "11px" | "10px" | CSS font size for labels. |