Wave Timeline
A waveform player with timeline markers built on wavesurfer.js.
Loading...
import WaveTimeline from "@/components/wave-timeline";export default function AudioTimelineDemo() { return ( <div className="w-full mx-auto p-6"> <WaveTimeline src="/coastline.mp3" title="SoundHelix — Song 1" /> </div> );}Installation
Install the required shadcn/ui primitives.
npx shadcn@latest add button slider card skeletonAdd the component via jsrepo.
npx jsrepo add @waves-cn/ui/wave-timelineThis will automatically install wave-cn, wavesurfer.js and lucide-react.
Install 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.
/** * 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-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 audio is being fetched and decoded.
- Shadcn theming —
waveColorandprogressColoraccept any CSS value includingvar(--*)tokens, resolved at runtime for the Canvas API viauseCssVar.
Usage
import { AudioTimeline } from "@/components/ui/wave-timeline"<AudioTimeline url="/coastline.mp3" />Examples
With dual timelines
Show both a top and bottom timeline ruler.
Loading...
import WaveTimeline from "@/components/wave-timeline";export default function AudioTimelineDualDemo() { 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/wave-timeline";export default function AudioTimelineNoRulerExample() { return ( <div className="w-full mx-auto p-6"> <WaveTimeline src="/coastline.mp3" title="No Ruler" topTimeline={false} /> </div> );}Custom zoom range
<AudioTimeline
url="/coastline.mp3"
defaultZoom={100}
minZoom={20}
maxZoom={1000}
/>API Reference
AudioTimeline
| Prop | Type | Default | Description |
|---|---|---|---|
url | string | — | (Required) Audio file URL to load. |
waveColor | string | var(--muted-foreground) | Audio 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 | Audio 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. |