Wave Zoom

A wave player with mouse-wheel zoom, built on wavesurfer.js ZoomPlugin.

Loading...
import { WaveZoom } from "@/components/waves-cn/wave-zoom";export default function WaveZoomDemo() {  return (    <div className="w-full  mx-auto p-6">      <WaveZoom url="/coastline.mp3" />    </div>  );}

Installation

pnpm dlx shadcn@latest add @waves-cn/wave-zoom
npx shadcn@latest add @waves-cn/wave-zoom
yarn dlx shadcn@latest add @waves-cn/wave-zoom
bunx --bun shadcn@latest add @waves-cn/wave-zoom

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.

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-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/waves-cn/wave-zoom"

Examples

Custom Colors

waveColor and progressColor accept any CSS value including var(--*) tokens.

Loading...
import { WaveZoom } from "@/components/waves-cn/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>  );}

Custom Zoom Range

Control how fast and how far the user can zoom.

Loading...
import { WaveZoom } from "@/components/waves-cn/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>  );}

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