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 skeleton

Add the component via jsrepo.

npx jsrepo add @waves-cn/ui/wave-zoom

This will automatically install wave-cn, wavesurfer.js and lucide-react.

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

npx shadcn@latest add button slider skeleton

Copy 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 into WavesurferPlayer via onReady.
  • Live zoom indicator — displays the current zoom level in px/s as you scroll.
  • Skip controls — forward and backward buttons jump by skipSeconds (default 5s).
  • Drag to seek — click or drag anywhere on the waveform to jump to that position.
  • Custom waveform stylingwaveColor, progressColor, barWidth, barGap, and barRadius accept any CSS value including var(--*) 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

PropTypeDefaultDescription
urlstringWave file URL to load.
waveColorstringvar(--muted-foreground)Wave bar color. Accepts CSS vars, hex, hsl, oklch.
progressColorstringvar(--primary)Progress bar color. Same formats as waveColor.
audioHeightnumber64Wave canvas height in px.
barWidthnumber3Bar width in px.
barGapnumber2Gap between bars in px.
barRadiusnumber2Bar border radius in px.
zoomScalenumber0.5Magnification per scroll step (0–1).
maxZoomnumber1000Maximum zoom level in px/s.
defaultZoomnumber100Initial zoom level in px/s (minPxPerSec).
skipSecondsnumber5Seconds to skip on forward/backward.
classNamestringRoot element class.
styleCSSPropertiesRoot element inline style.

On this page