Wave Zoom
A wave player with mouse-wheel zoom, built on wavesurfer.js ZoomPlugin.
Loading...
import { WaveZoom } from "@/components/wave-zoom";export default function WaveZoomDemo() { return ( <div className="w-full mx-auto p-6"> <WaveZoom url="/coastline.mp3" /> </div> );}Installation
Install the required shadcn/ui primitives.
npx shadcn@latest add button slider skeletonAdd the component via jsrepo.
npx jsrepo add @waves-cn/ui/wave-zoomThis 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 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-zoom.tsx into your project.
import { useRef, useState, useEffect, useCallback, useMemo, type CSSProperties,} from "react";import ZoomPlugin from "wavesurfer.js/dist/plugins/zoom.esm.js";import { Button } from "@/components/ui/button";import { Switch } from "@/components/ui/switch";import { Label } from "@/components/ui/label";import { Play, Pause, SkipBack, SkipForward } from "lucide-react";import { cn } from "@/lib/utils";import WavesurferPlayer from "@/lib/wave-cn";import type WaveSurfer from "wavesurfer.js";/** * Props for the WaveZoom component */export type WaveZoomProps = { /** Wave file URL to load */ url: string; /** Wave 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; /** Wave canvas height in px @default 64 */ waveHeight?: number; /** Bar width in px @default 3 */ barWidth?: number; /** Gap between bars in px @default 2 */ barGap?: number; /** Bar border radius in px @default 2 */ barRadius?: number; /** Zoom magnification per scroll step @default 0.5 */ zoomScale?: number; /** Maximum zoom level in px/s @default 1000 */ maxZoom?: number; /** Initial zoom level in px/s @default 100 */ defaultZoom?: number; /** Seconds to skip on forward/backward @default 5 */ skipSeconds?: number; /** Root element class */ className?: string; style?: CSSProperties;};/** * Wave player with mouse-wheel zoom via ZoomPlugin */export function WaveZoom({ url, waveColor, progressColor, waveHeight, barWidth, barGap, barRadius, zoomScale = 0.5, maxZoom = 1000, defaultZoom = 100, skipSeconds = 5, className, style,}: WaveZoomProps) { const wavesurferRef = useRef<WaveSurfer | null>(null); const waveHeightRef = useRef(waveHeight ?? 64); waveHeightRef.current = waveHeight ?? 64; const [isPlaying, setIsPlaying] = useState(false); const [isReady, setIsReady] = useState(false); const [currentZoom, setCurrentZoom] = useState(defaultZoom); const [autoScroll, setAutoScroll] = useState(true); const [fillParent, setFillParent] = useState(true); const [autoCenter, setAutoCenter] = useState(true); const plugins = useMemo( () => [ZoomPlugin.create({ scale: zoomScale, maxZoom })], [], ); useEffect(() => { const ws = wavesurferRef.current; if (!ws || !isReady) return; ws.setOptions({ autoScroll, fillParent, autoCenter }); }, [isReady, autoScroll, fillParent, autoCenter]); const togglePlay = useCallback(() => wavesurferRef.current?.playPause(), []); const forward = useCallback( () => wavesurferRef.current?.skip(skipSeconds), [skipSeconds], ); const backward = useCallback( () => wavesurferRef.current?.skip(-skipSeconds), [skipSeconds], ); const handleReady = useCallback((ws: WaveSurfer) => { wavesurferRef.current = ws; setIsReady(true); ws.on("zoom", (minPxPerSec) => { setCurrentZoom(Math.round(minPxPerSec)); }); }, []); return ( <div className={cn("w-full space-y-4", className)} style={style}> <p className="text-xs text-muted-foreground"> Zoom:{" "} <span className="tabular-nums font-medium text-foreground"> {currentZoom} </span>{" "} px/s <span className="ml-2 opacity-60">— scroll to zoom</span> </p> <div className="w-full rounded-md overflow-hidden bg-muted/40"> <WavesurferPlayer url={url} waveColor={waveColor} progressColor={progressColor} height={waveHeight} barWidth={barWidth} barGap={barGap} barRadius={barRadius} minPxPerSec={defaultZoom} dragToSeek autoScroll={autoScroll} fillParent={fillParent} autoCenter={autoCenter} plugins={plugins} onReady={handleReady} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onFinish={() => setIsPlaying(false)} onDestroy={() => { wavesurferRef.current = null; setIsReady(false); }} /> </div> <div className="flex flex-wrap items-center gap-x-6 gap-y-2"> {( [ { label: "Auto scroll", value: autoScroll, onChange: setAutoScroll, }, { label: "Fill parent", value: fillParent, onChange: setFillParent, }, { label: "Auto center", value: autoCenter, onChange: setAutoCenter, }, ] as const ).map(({ label, value, onChange }) => ( <div key={label} className="flex items-center gap-2"> <Switch id={label} checked={value} onCheckedChange={onChange} disabled={!isReady} /> <Label htmlFor={label} className="text-sm text-muted-foreground cursor-pointer" > {label} </Label> </div> ))} </div> <div className="flex items-center gap-2"> <Button size="icon" variant="outline" onClick={backward} disabled={!isReady} aria-label={`Backward ${skipSeconds}s`} > <SkipBack className="size-4" /> </Button> <Button size="icon" onClick={togglePlay} disabled={!isReady} aria-label={isPlaying ? "Pause" : "Play"} > {isPlaying ? ( <Pause className="size-4" /> ) : ( <Play className="size-4" /> )} </Button> <Button size="icon" variant="outline" onClick={forward} disabled={!isReady} aria-label={`Forward ${skipSeconds}s`} > <SkipForward className="size-4" /> </Button> </div> </div> );}export default WaveZoom;Update the import paths to match your project setup.
Features
- Mouse-wheel zoom — scroll anywhere on the waveform to zoom in or out. Powered by wavesurfer.js
ZoomPlugin, injected intoWavesurferPlayerviaonReady. - Live zoom indicator — displays the current zoom level in
px/sas you scroll. - Skip controls — forward and backward buttons jump by
skipSeconds(default5s). - Drag to seek — click or drag anywhere on the waveform to jump to that position.
- Custom waveform styling —
waveColor,progressColor,barWidth,barGap, andbarRadiusaccept any CSS value includingvar(--*)tokens.
Usage
import { WaveZoom } from "@/components/ui/wave-zoom"<WaveZoom url="/coastline.mp3" />Examples
Custom Colors
waveColor and progressColor accept any CSS value including var(--*) tokens.
Loading...
import { WaveZoom } from "@/components/wave-zoom";export default function WaveZoomCustomWave() { return ( <div className="w-full mx-auto p-6"> <WaveZoom url="/coastline.mp3" waveColor="var(--chart-1)" progressColor="var(--chart-2)" barWidth={4} barGap={3} barRadius={8} /> </div> );}<WaveZoom
url="/coastline.mp3"
waveColor="var(--chart-1)"
progressColor="var(--chart-2)"
barWidth={4}
barGap={3}
barRadius={8}
/>Custom Zoom Range
Control how fast and how far the user can zoom.
Loading...
import { WaveZoom } from "@/components/wave-zoom";export default function WaveZoomCustomRange() { return ( <div className="w-full mx-auto p-6"> <WaveZoom url="/coastline.mp3" zoomScale={0.25} maxZoom={500} defaultZoom={50} /> </div> );}<WaveZoom
url="/coastline.mp3"
zoomScale={0.25}
maxZoom={500}
defaultZoom={50}
/>API Reference
WaveZoom
| Prop | Type | Default | Description |
|---|---|---|---|
url | string | — | Wave file URL to load. |
waveColor | string | var(--muted-foreground) | Wave bar color. Accepts CSS vars, hex, hsl, oklch. |
progressColor | string | var(--primary) | Progress bar color. Same formats as waveColor. |
audioHeight | number | 64 | Wave canvas height in px. |
barWidth | number | 3 | Bar width in px. |
barGap | number | 2 | Gap between bars in px. |
barRadius | number | 2 | Bar border radius in px. |
zoomScale | number | 0.5 | Magnification per scroll step (0–1). |
maxZoom | number | 1000 | Maximum zoom level in px/s. |
defaultZoom | number | 100 | Initial zoom level in px/s (minPxPerSec). |
skipSeconds | number | 5 | Seconds to skip on forward/backward. |
className | string | — | Root element class. |
style | CSSProperties | — | Root element inline style. |