Wave Regions

A shadcn-styled waveform player with draggable and resizable region markers built on wavesurfer.js RegionsPlugin.

Loading...
import WaveRegions from "@/components/waves-cn/wave-regions";export default function WaveRegionsDemo() {  return (    <div className="w-full mx-auto p-6">      <WaveRegions        src="/coastline.mp3"        title="Interactive Regions"      />    </div>  );}

Installation

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

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

npx shadcn@latest add card button slider skeleton

Copy wave-cn.tsx into your project.

import {  useCallback,  useEffect,  useMemo,  useRef,  useState,  memo,  type ReactElement,  type RefObject,} from "react";import WaveSurfer, {  type WaveSurferEvents,  type WaveSurferOptions,} from "wavesurfer.js";// ─── Types ───────────────────────────────────────────────────────────────────type WavesurferEventHandler<T extends unknown[]> = (  wavesurfer: WaveSurfer,  ...args: T) => void;/** `onReady`, `onPlay`, `onTimeupdate`, … — one prop per wavesurfer.js event. */export type OnWavesurferEvents = {  [K in keyof WaveSurferEvents as `on${Capitalize<K>}`]?: WavesurferEventHandler<    WaveSurferEvents[K]  >;};export type PartialWavesurferOptions = Omit<WaveSurferOptions, "container">;type WavesurferPlugin = NonNullable<WaveSurferOptions["plugins"]>[number];export type WavesurferProps = PartialWavesurferOptions &  OnWavesurferEvents & {    className?: string;  };// ─── Defaults ────────────────────────────────────────────────────────────────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>;// ─── Helpers ─────────────────────────────────────────────────────────────────/** Formats seconds as `m:ss`. Negative or non-finite input renders as `0:00`. */export function formatTime(seconds: number): string {  if (!Number.isFinite(seconds) || seconds < 0) return "0:00";  const m = Math.floor(seconds / 60);  const s = Math.floor(seconds % 60);  return `${m}:${s.toString().padStart(2, "0")}`;}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;/** * Options that require a brand-new WaveSurfer instance when they change. * Every other option is applied in place through `wavesurfer.setOptions()`, * so cosmetic changes (bar width, height, colors…) never re-fetch the audio. * * `plugins` and `peaks` are compared by reference: memoize them in the caller * (`useMemo`) or the player will be recreated on every render. */const STRUCTURAL_KEYS = [  "url",  "media",  "plugins",  "peaks",  "duration",  "sampleRate",  "backend",  "mediaControls",] as const satisfies readonly (keyof WaveSurferOptions)[];type StructuralKey = (typeof STRUCTURAL_KEYS)[number];function isStructuralKey(key: string): key is StructuralKey {  return (STRUCTURAL_KEYS as readonly string[]).includes(key);}function splitProps(props: WavesurferProps) {  const { className, ...rest } = props;  const options: Partial<WaveSurferOptions> = {};  const events: OnWavesurferEvents = {};  for (const key in rest) {    const value = rest[key as keyof typeof rest];    if (isEventProp(key)) {      events[key as keyof OnWavesurferEvents] = value as never;    } else {      options[key as keyof PartialWavesurferOptions] = value as never;    }  }  return { className, options, events };}function pickUpdatableOptions(  options: Partial<WaveSurferOptions>,): Partial<WaveSurferOptions> {  const out: Partial<WaveSurferOptions> = {};  for (const key in options) {    if (isStructuralKey(key)) continue;    const value = options[key as keyof WaveSurferOptions];    if (value === undefined) continue; // keep WAVESURFER_DEFAULTS in place    out[key as keyof WaveSurferOptions] = value as never;  }  return out;}/** * Keys whose value changed between two option objects. Only those are sent to * `setOptions()`, so re-rendering with the same props never resets state that * the user changed through the instance (zoom level, playback rate…). */function diffOptions(  prev: Partial<WaveSurferOptions>,  next: Partial<WaveSurferOptions>,): Partial<WaveSurferOptions> {  const patch: Partial<WaveSurferOptions> = {};  const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);  for (const key of keys) {    const k = key as keyof WaveSurferOptions;    if (prev[k] !== next[k]) patch[k] = next[k] as never;  }  return patch;}/** Stable string for the updatable, serialisable part of the options. */function updatableOptionsKey(options: Partial<WaveSurferOptions>): string {  const out: Record<string, unknown> = {};  for (const key in options) {    if (isStructuralKey(key)) continue;    const value = options[key as keyof WaveSurferOptions];    if (typeof value === "function") continue;    if (typeof HTMLElement !== "undefined" && value instanceof HTMLElement)      continue;    out[key] = value;  }  return JSON.stringify(out);}/** * Plugins passed to `<WavesurferPlayer>` act as templates. wavesurfer.js destroys * plugins together with the instance, so a fresh copy is created from each * template every time the instance is (re)created. */function instantiatePlugins(  plugins: WaveSurferOptions["plugins"],): WaveSurferOptions["plugins"] {  return plugins?.map((plugin) => {    const Plugin = plugin.constructor as new (      options: unknown,    ) => WavesurferPlugin;    const options = (plugin as unknown as { options?: unknown }).options;    return new Plugin(options);  });}// ─── CSS var resolver ────────────────────────────────────────────────────────const CSS_VAR_RE = /^var\((--[^,)]+)(?:\s*,\s*([^)]+))?\)$/;function resolveCssVar(value: string): string {  if (typeof window === "undefined") return value;  const match = CSS_VAR_RE.exec(value.trim());  if (!match) return value;  const [, name, fallback] = match;  const root = getComputedStyle(document.documentElement)    .getPropertyValue(name)    .trim();  const body = root    ? ""    : getComputedStyle(document.body).getPropertyValue(name).trim();  const raw = root || body || fallback?.trim() || "";  if (!raw) return value;  // shadcn v3 stored bare HSL channels ("222.2 47.4% 11.2%"); canvas needs a full color.  const isHslChannels = /^[\d.]+\s+[\d.]+%\s+[\d.]+%$/.test(raw);  return isHslChannels ? `hsl(${raw})` : raw;}/** * Resolves a `var(--token)` string to its computed value so it can be handed to * a `<canvas>` (wavesurfer, minimap, spectrogram…). Re-resolves when the theme * class / `data-theme` attribute changes on `<html>` or `<body>`. * * Non-`var()` values pass straight through. */export function useCssVar(value: string): string {  const [resolved, setResolved] = useState(() => resolveCssVar(value));  const [prevValue, setPrevValue] = useState(value);  // Re-resolve synchronously when the input changes (derived state pattern).  if (prevValue !== value) {    setPrevValue(value);    setResolved(resolveCssVar(value));  }  useEffect(() => {    if (!CSS_VAR_RE.test(value.trim())) return;    const update = () => setResolved(resolveCssVar(value));    const observer = new MutationObserver(update);    const config = {      attributes: true,      attributeFilter: ["class", "style", "data-theme"],    };    observer.observe(document.documentElement, config);    observer.observe(document.body, config);    return () => observer.disconnect();  }, [value]);  return resolved;}// ─── <WavesurferPlayer> ──────────────────────────────────────────────────────function WavesurferPlayerBase(props: WavesurferProps): ReactElement {  const containerRef = useRef<HTMLDivElement | null>(null);  const wsRef = useRef<WaveSurfer | null>(null);  const { className, options, events } = splitProps(props);  // Colors: resolve CSS vars for the canvas, re-resolve on theme change.  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);  // Latest props in refs so the creation effect never needs them as deps.  const latest = useRef({ options, events, resolvedWaveColor, resolvedProgressColor });  useEffect(() => {    latest.current = { options, events, resolvedWaveColor, resolvedProgressColor };  });  const [isReady, setIsReady] = useState(false);  /** Options last applied to the current instance (creation or setOptions). */  const applied = useRef<Partial<WaveSurferOptions> | null>(null);  const { url, media, plugins, peaks, duration, sampleRate, backend, mediaControls } =    options;  const height = options.height ?? WAVESURFER_DEFAULTS.height;  // ── Create / destroy the instance  useEffect(() => {    const container = containerRef.current;    if (!container) return;    const current = latest.current;    const initial = {      ...pickUpdatableOptions(current.options),      waveColor: current.resolvedWaveColor,      progressColor: current.resolvedProgressColor,    };    const ws = WaveSurfer.create({      ...WAVESURFER_DEFAULTS,      ...initial,      url,      media,      peaks,      duration,      sampleRate,      backend,      mediaControls,      plugins: instantiatePlugins(plugins),      container,    });    wsRef.current = ws;    applied.current = initial;    const unsubs: Array<() => void> = [      ws.on("load", () => setIsReady(false)),      ws.on("ready", () => setIsReady(true)),      ws.on("destroy", () => setIsReady(false)),    ];    // Forward every `onX` prop present at mount to the matching wavesurfer event.    // Handlers are read through the ref at call time, so parents can pass inline    // functions without causing re-subscription.    for (const name of Object.keys(current.events)) {      const event = getEventName(name);      unsubs.push(        ws.on(event, (...args) => {          const handler = latest.current.events[            name as keyof OnWavesurferEvents          ] as WavesurferEventHandler<WaveSurferEvents[typeof event]> | undefined;          handler?.(ws, ...args);        }),      );    }    return () => {      unsubs.forEach((fn) => fn());      ws.destroy();      wsRef.current = null;      applied.current = null;    };  }, [url, media, plugins, peaks, duration, sampleRate, backend, mediaControls]);  // ── Apply changed options in place (never the full set: see diffOptions)  const optionsKey = updatableOptionsKey(options);  useEffect(() => {    const ws = wsRef.current;    if (!ws || !applied.current) return;    const next = {      ...pickUpdatableOptions(latest.current.options),      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    };    const patch = diffOptions(applied.current, next);    applied.current = next;    if (Object.keys(patch).length > 0) ws.setOptions(patch);  }, [optionsKey, resolvedWaveColor, resolvedProgressColor]);  return (    <div className={className} style={{ position: "relative" }}>      {!isReady && (        <div          aria-hidden          className="absolute inset-0 animate-pulse rounded bg-muted"          style={{ height }}        />      )}      <div ref={containerRef} style={isReady ? undefined : { opacity: 0 }} />    </div>  );}/** * Declarative wrapper around `WaveSurfer.create()`. * * - Every `WaveSurferOptions` key is accepted as a prop. * - Every wavesurfer event is available as `on<Event>` (`onReady`, `onTimeupdate`…). * - Changing `url`, `media`, `plugins`, `peaks`, `duration`, `sampleRate`, *   `backend` or `mediaControls` recreates the instance; anything else is applied *   through `setOptions()` without re-decoding. * - Memoize `plugins` and `peaks` (they are compared by reference). */const WavesurferPlayer = memo(WavesurferPlayerBase, (prev, next) => {  // Event handler identity never matters (read through a ref); everything else does.  const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);  for (const key of keys) {    if (isEventProp(key)) continue;    if (prev[key as keyof WavesurferProps] !== next[key as keyof WavesurferProps])      return false;  }  return true;});WavesurferPlayer.displayName = "WavesurferPlayer";export default WavesurferPlayer;// ─── useWavesurfer ───────────────────────────────────────────────────────────/** * Imperative counterpart of `<WavesurferPlayer>`: you own the container element, * the hook owns the instance. Same recreate / setOptions rules apply. */export function useWavesurfer({  container,  waveColor = WAVESURFER_DEFAULTS.waveColor,  progressColor = WAVESURFER_DEFAULTS.progressColor,  ...options}: PartialWavesurferOptions & {  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 latest = useRef({ options, resolvedWaveColor, resolvedProgressColor });  useEffect(() => {    latest.current = { options, resolvedWaveColor, resolvedProgressColor };  });  const applied = useRef<Partial<WaveSurferOptions> | null>(null);  const { url, media, plugins, peaks, duration, sampleRate, backend, mediaControls } =    options;  useEffect(() => {    if (!container.current) return;    const current = latest.current;    const initial = {      ...pickUpdatableOptions(current.options),      waveColor: current.resolvedWaveColor,      progressColor: current.resolvedProgressColor,    };    const ws = WaveSurfer.create({      ...WAVESURFER_DEFAULTS,      ...initial,      url,      media,      peaks,      duration,      sampleRate,      backend,      mediaControls,      plugins: instantiatePlugins(plugins),      container: container.current,    });    applied.current = initial;    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("finish", () => setIsPlaying(false)),      ws.on("timeupdate", (t) => setCurrentTime(t)),      ws.on("destroy", () => {        setIsReady(false);        setIsPlaying(false);      }),    ];    setWavesurfer(ws);    return () => {      unsubs.forEach((fn) => fn());      ws.destroy();      applied.current = null;      setWavesurfer(null);    };  }, [    container,    url,    media,    plugins,    peaks,    duration,    sampleRate,    backend,    mediaControls,  ]);  const optionsKey = updatableOptionsKey(options);  useEffect(() => {    if (!wavesurfer || !applied.current) return;    const next = {      ...pickUpdatableOptions(latest.current.options),      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    };    const patch = diffOptions(applied.current, next);    applied.current = next;    if (Object.keys(patch).length > 0) wavesurfer.setOptions(patch);  }, [wavesurfer, optionsKey, resolvedWaveColor, resolvedProgressColor]);  return { wavesurfer, isReady, isPlaying, currentTime };}// ─── useWavePlayer ───────────────────────────────────────────────────────────export interface UseWavePlayerOptions {  /** Initial volume, 0–1 @default 1 */  defaultVolume?: number;  /** Start playback as soon as the audio is decoded */  autoPlay?: boolean;  /** Called once the audio is decoded, with the WaveSurfer instance */  onReady?: (wavesurfer: WaveSurfer) => void;  onPlay?: () => void;  onPause?: () => void;  onFinish?: () => void;  /** Called on every playback tick */  onTimeUpdate?: (currentTime: number, duration: number) => void;}export type WavePlayerHandlers = Pick<  OnWavesurferEvents,  | "onReady"  | "onPlay"  | "onPause"  | "onFinish"  | "onTimeupdate"  | "onSeeking"  | "onDestroy">;/** * Player state + controls shared by every waves-cn component. * * ```tsx * const player = useWavePlayer({ defaultVolume: 0.8, onPlay }); * <WavesurferPlayer url={src} {...player.handlers} /> * <Button onClick={player.togglePlay}>{player.isPlaying ? "Pause" : "Play"}</Button> * ``` */export function useWavePlayer(options: UseWavePlayerOptions = {}) {  const { defaultVolume = 1 } = options;  const wavesurfer = useRef<WaveSurfer | null>(null);  const latest = useRef(options);  useEffect(() => {    latest.current = options;  });  const [isReady, setIsReady] = useState(false);  const [isPlaying, setIsPlaying] = useState(false);  const [currentTime, setCurrentTime] = useState(0);  const [duration, setDuration] = useState(0);  const [volume, setVolumeState] = useState(defaultVolume);  const [isMuted, setIsMuted] = useState(false);  const volumeRef = useRef(defaultVolume);  const handlers = useMemo<WavePlayerHandlers>(    () => ({      onReady: (ws, dur) => {        wavesurfer.current = ws;        ws.setVolume(volumeRef.current);        setDuration(dur);        setCurrentTime(ws.getCurrentTime());        setIsReady(true);        if (latest.current.autoPlay) void ws.play();        latest.current.onReady?.(ws);      },      onPlay: () => {        setIsPlaying(true);        latest.current.onPlay?.();      },      onPause: () => {        setIsPlaying(false);        latest.current.onPause?.();      },      onFinish: () => {        setIsPlaying(false);        latest.current.onFinish?.();      },      onTimeupdate: (ws, t) => {        setCurrentTime(t);        latest.current.onTimeUpdate?.(t, ws.getDuration());      },      onSeeking: (_ws, t) => setCurrentTime(t),      onDestroy: () => {        wavesurfer.current = null;        setIsReady(false);        setIsPlaying(false);        setCurrentTime(0);        setDuration(0);      },    }),    [],  );  const play = useCallback(() => void wavesurfer.current?.play(), []);  const pause = useCallback(() => wavesurfer.current?.pause(), []);  const togglePlay = useCallback(    () => void wavesurfer.current?.playPause(),    [],  );  const restart = useCallback(() => {    const ws = wavesurfer.current;    if (!ws) return;    ws.setTime(0);    void ws.play();  }, []);  /** Seek to a fraction of the duration (0–1). */  const seek = useCallback((fraction: number) => {    wavesurfer.current?.seekTo(Math.min(1, Math.max(0, fraction)));  }, []);  /** Seek to an absolute time in seconds. */  const seekTo = useCallback((seconds: number) => {    wavesurfer.current?.setTime(seconds);  }, []);  const setVolume = useCallback((next: number) => {    const value = Math.min(1, Math.max(0, next));    volumeRef.current = value;    setVolumeState(value);    setIsMuted(false);    wavesurfer.current?.setMuted(false);    wavesurfer.current?.setVolume(value);  }, []);  const toggleMute = useCallback(() => {    const next = !isMuted;    setIsMuted(next);    wavesurfer.current?.setMuted(next);  }, [isMuted]);  const progress = duration > 0 ? currentTime / duration : 0;  return {    /** The live WaveSurfer instance (null until ready). */    wavesurfer,    isReady,    isPlaying,    currentTime,    duration,    /** currentTime / duration, 0–1 */    progress,    volume,    isMuted,    play,    pause,    togglePlay,    restart,    seek,    seekTo,    setVolume,    toggleMute,    /** Spread onto `<WavesurferPlayer>`: `<WavesurferPlayer {...player.handlers} />` */    handlers,  };}

Copy wave-regions.tsx into your project.

import * as React from "react";import RegionsPlugin, {  type Region,} from "wavesurfer.js/dist/plugins/regions.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,  RotateCcw,  Repeat,  Trash2,} from "lucide-react";import WavesurferPlayer, { formatTime, useWavePlayer } from "@/lib/wave-cn";import type WaveSurfer from "wavesurfer.js";// ─── Types ───────────────────────────────────────────────────────────────────export interface RegionData {  id: string;  start: number;  end: number;  color?: string;  content?: string;}export interface WaveRegionsProps {  /** Audio source URL */  src: string;  /** Optional title shown above the waveform */  title?: string;  /** Initial volume (0–1) */  defaultVolume?: number;  /** Allow creating and editing regions */  editable?: boolean;  /** Default color for new regions @default "color-mix(in oklab, var(--primary) 25%, transparent)" */  regionColor?: string;  /** Color for the active (looping) region @default "color-mix(in oklab, var(--primary) 45%, transparent)" */  activeRegionColor?: string;  /** Audio 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;  /** Waveform bar width in px @default 3 */  barWidth?: number;  /** Waveform bar gap in px @default 2 */  barGap?: number;  /** Rounded borders for bars @default 2 */  barRadius?: number;  /** Waveform height in px @default 64 */  waveHeight?: number;  /** Called when a region is created by the user */  onRegionCreated?: (region: RegionData) => void;  /** Called when a region is updated (moved/resized) */  onRegionUpdated?: (region: RegionData) => void;  /** Called when a region is removed */  onRegionRemoved?: (id: string) => void;  /** Called when a region is clicked */  onRegionClicked?: (region: RegionData) => void;  /** Called when playback starts */  onPlay?: () => void;  /** Called when playback pauses */  onPause?: () => void;  /** Called when playback finishes */  onFinish?: () => void;  /** Called with current time on every audio process tick */  onTimeUpdate?: (currentTime: number, duration: number) => void;  className?: string;}type RegionsPluginInstance = InstanceType<typeof RegionsPlugin>;// ─── Helpers ─────────────────────────────────────────────────────────────────function toRegionData(r: Region): RegionData {  return {    id: r.id,    start: r.start,    end: r.end,    color: r.color,    content: r.content?.textContent ?? undefined,  };}// ─── Component ───────────────────────────────────────────────────────────────export function WaveRegions({  src,  title,  defaultVolume = 0.8,  editable = true,  regionColor = "color-mix(in oklab, var(--primary) 25%, transparent)",  activeRegionColor = "color-mix(in oklab, var(--primary) 45%, transparent)",  waveColor,  progressColor,  barWidth,  barGap,  barRadius,  waveHeight,  onRegionCreated,  onRegionUpdated,  onRegionRemoved,  onRegionClicked,  onPlay,  onPause,  onFinish,  onTimeUpdate,  className,}: WaveRegionsProps) {  const [regionsPlugin, setRegionsPlugin] =    React.useState<RegionsPluginInstance | null>(null);  const [regions, setRegions] = React.useState<RegionData[]>([]);  const [activeRegionId, setActiveRegionId] = React.useState<string | null>(    null,  );  const [isLooping, setIsLooping] = React.useState(false);  // Latest props/state readable from plugin listeners without re-subscribing.  const latest = React.useRef({    editable,    regionColor,    activeRegionId,    onRegionCreated,    onRegionUpdated,    onRegionRemoved,    onRegionClicked,  });  React.useEffect(() => {    latest.current = {      editable,      regionColor,      activeRegionId,      onRegionCreated,      onRegionUpdated,      onRegionRemoved,      onRegionClicked,    };  });  // ── Memoized plugin ──────────────────────────────────────────────────────  const plugins = React.useMemo(    () => (typeof document === "undefined" ? [] : [RegionsPlugin.create()]),    [],  );  // ── Player ───────────────────────────────────────────────────────────────  const player = useWavePlayer({    defaultVolume,    onPlay,    onPause,    onFinish,    onTimeUpdate,    onReady: (ws) => {      const live = ws        .getActivePlugins()        .find((p) => p instanceof RegionsPlugin) as        | RegionsPluginInstance        | undefined;      setRegionsPlugin(live ?? null);    },  });  const handlers = React.useMemo(    () => ({      ...player.handlers,      onDestroy: (ws: WaveSurfer) => {        player.handlers.onDestroy?.(ws);        setRegionsPlugin(null);        setRegions([]);        setActiveRegionId(null);        setIsLooping(false);      },    }),    [player.handlers],  );  // ── Drag selection ───────────────────────────────────────────────────────  React.useEffect(() => {    if (!regionsPlugin || !editable) return;    return regionsPlugin.enableDragSelection({      color: regionColor,      resize: true,      drag: true,    });  }, [regionsPlugin, editable, regionColor]);  // ── Region events ────────────────────────────────────────────────────────  React.useEffect(() => {    const rp = regionsPlugin;    if (!rp) return;    const unsubs = [      rp.on("region-created", (region: Region) => {        const { editable, regionColor, onRegionCreated } = latest.current;        if (editable) {          region.setOptions({ color: regionColor, resize: true, drag: true });        }        const data = toRegionData(region);        setRegions((prev) => {          if (prev.some((r) => r.id === data.id)) return prev;          return [...prev, data];        });        onRegionCreated?.(data);      }),      rp.on("region-updated", (region: Region) => {        const data = toRegionData(region);        setRegions((prev) =>          prev.map((r) => (r.id === data.id ? data : r)),        );        latest.current.onRegionUpdated?.(data);      }),      rp.on("region-removed", (region: Region) => {        setRegions((prev) => prev.filter((r) => r.id !== region.id));        setActiveRegionId((prev) => (prev === region.id ? null : prev));        if (latest.current.activeRegionId === region.id) setIsLooping(false);        latest.current.onRegionRemoved?.(region.id);      }),      rp.on("region-clicked", (region: Region, e: MouseEvent) => {        e.stopPropagation();        setActiveRegionId(region.id);        latest.current.onRegionClicked?.(toRegionData(region));      }),    ];    return () => unsubs.forEach((u) => u());  }, [regionsPlugin]);  // ── Loop active region ───────────────────────────────────────────────────  React.useEffect(() => {    const ws = player.wavesurfer.current;    const rp = regionsPlugin;    if (!ws || !rp || !isLooping || !activeRegionId) return;    const active = rp.getRegions().find((r) => r.id === activeRegionId);    if (!active) return;    // Play the active region    active.play();    const unsub = rp.on("region-out", (region) => {      if (region.id === active.id) active.play();    });    return () => unsub();  }, [player.wavesurfer, regionsPlugin, isLooping, activeRegionId]);  // ── Highlight active region ──────────────────────────────────────────────  React.useEffect(() => {    if (!regionsPlugin) return;    regionsPlugin.getRegions().forEach((r) => {      r.setOptions({        color: r.id === activeRegionId ? activeRegionColor : regionColor,      });    });  }, [regionsPlugin, activeRegionId, regionColor, activeRegionColor]);  // ── Controls ─────────────────────────────────────────────────────────────  const { seek, setVolume } = player;  const handleSeek = React.useCallback(([v]: number[]) => seek(v), [seek]);  const handleVolume = React.useCallback(    ([v]: number[]) => setVolume(v),    [setVolume],  );  const toggleLoop = React.useCallback(() => {    setIsLooping((prev) => !prev);  }, []);  const removeActiveRegion = React.useCallback(() => {    if (!activeRegionId || !regionsPlugin) return;    const active = regionsPlugin      .getRegions()      .find((r) => r.id === activeRegionId);    active?.remove();  }, [regionsPlugin, activeRegionId]);  // ── Derived ──────────────────────────────────────────────────────────────  const { isReady, isPlaying, isMuted, volume, currentTime, duration, progress } =    player;  const activeRegion = activeRegionId    ? regions.find((r) => r.id === activeRegionId)    : undefined;  // ── Render ─────────────────────────────────────────────────────────────  return (    <Card      className={cn(        "w-full px-0 border-0 rounded-none bg-transparent",        className,      )}    >      <CardContent className="border-0 px-0 space-y-3">        {title && (          <p className="text-sm font-medium text-foreground truncate">            {title}          </p>        )}        {/* Waveform with regions */}        <div className="relative w-full rounded-sm overflow-hidden bg-muted/40">          <WavesurferPlayer            url={src}            waveColor={waveColor}            progressColor={progressColor}            height={waveHeight}            barWidth={barWidth}            barGap={barGap}            barRadius={barRadius}            dragToSeek            plugins={plugins}            {...handlers}          />        </div>        {/* Region info */}        {regions.length > 0 && (          <p className="text-xs text-muted-foreground">            {regions.length} region{regions.length !== 1 ? "s" : ""}            {activeRegionId && (              <>                {" · "}                <span className="font-medium text-foreground">                  Active: {formatTime(activeRegion?.start ?? 0)}–                  {formatTime(activeRegion?.end ?? 0)}                </span>              </>            )}          </p>        )}        {/* 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}            aria-label="Seek"          />          <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">          <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={player.restart}              aria-label="Restart"            >              <RotateCcw size={15} />            </Button>            <Button              size="icon"              variant="secondary"              className="h-9 w-9"              disabled={!isReady}              onClick={player.togglePlay}              aria-label={isPlaying ? "Pause" : "Play"}            >              {isPlaying ? <Pause size={17} /> : <Play size={17} />}            </Button>            {/* Loop active region */}            <Button              size="icon"              variant={isLooping ? "default" : "ghost"}              className="h-8 w-8 text-muted-foreground hover:text-foreground"              disabled={!isReady || !activeRegionId}              onClick={toggleLoop}              aria-label={isLooping ? "Stop looping" : "Loop active region"}            >              <Repeat size={15} />            </Button>            {/* Remove active region */}            {editable && activeRegionId && (              <Button                size="icon"                variant="ghost"                className="h-8 w-8 text-muted-foreground hover:text-destructive"                onClick={removeActiveRegion}                aria-label="Remove active region"              >                <Trash2 size={15} />              </Button>            )}          </div>          {/* Volume */}          <div className="flex items-center gap-2 w-36">            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              onClick={player.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>        {/* Hint */}        {editable && isReady && regions.length === 0 && (          <p className="text-xs text-muted-foreground/60 text-center">            Click and drag on the waveform to create a region          </p>        )}      </CardContent>    </Card>  );}export default WaveRegions;

Update the import paths to match your project setup.

Features

  • Interactive Regions — allows creating regions by dragging on the waveform.
  • Resize & Move — region handles allow modifying boundaries interactively.
  • Looping — select a region and click the repeat button to loop play it.
  • Events — fires callbacks when regions are created, updated, deleted, or clicked.

Usage

import { WaveRegions } from "@/components/waves-cn/wave-regions"
<WaveRegions src="/coastline.mp3" />

Examples

Readonly Regions

You can disable region creation and editing.

Loading...
import WaveRegions from "@/components/waves-cn/wave-regions";export default function WaveRegionsReadonlyDemo() {  return (    <div className="w-full mx-auto p-6">      <WaveRegions        src="/coastline.mp3"        title="Readonly Regions"        editable={false}      />    </div>  );}

API Reference

PropTypeDefaultDescription
srcstring—(Required) Audio URL. Changing it recreates the wavesurfer instance.
titlestring—Track title displayed above the waveform.
defaultVolumenumber0.8Initial volume 0–1.
editablebooleantrueAllow drag-selecting new regions and moving / resizing existing ones.
regionColorstringcolor-mix(in oklab, var(--primary) 25%, transparent)Fill color of regions.
activeRegionColorstringcolor-mix(in oklab, var(--primary) 45%, transparent)Fill color of the selected (loopable) region.
onRegionCreated(region: RegionData) => void—Fired when the user creates a region.
onRegionUpdated(region: RegionData) => void—Fired after a region is moved or resized.
onRegionRemoved(id: string) => void—Fired when a region is removed.
onRegionClicked(region: RegionData) => void—Fired when a region is clicked. The region becomes active.
onPlay() => void—Fired when playback starts.
onPause() => void—Fired when playback pauses.
onFinish() => void—Fired when the track ends.
onTimeUpdate(currentTime: number, duration: number) => void—Fired on every playback tick.
waveColorstringvar(--muted-foreground)Waveform bar color. Accepts any CSS value including var(--*) tokens.
progressColorstringvar(--primary)Progress overlay color. Same formats as waveColor.
waveHeightnumber64Waveform canvas height in px.
barWidthnumber3Bar width in px.
barGapnumber2Gap between bars in px.
barRadiusnumber2Border radius of each bar.
classNamestring—Extra classes on the outer <Card>.

RegionData

FieldTypeDescription
idstringStable id assigned by wavesurfer.
startnumberStart time in seconds.
endnumberEnd time in seconds.
colorstringCurrent fill color.
contentstringText content, if any.

On this page