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-regionsnpx shadcn@latest add @waves-cn/wave-regionsyarn dlx shadcn@latest add @waves-cn/wave-regionsbunx --bun shadcn@latest add @waves-cn/wave-regionsInstall the peer dependency.
npm install wavesurfer.jsInstall the required shadcn primitives.
npx shadcn@latest add card button slider skeletonCopy 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
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | (Required) Audio URL. Changing it recreates the wavesurfer instance. |
title | string | — | Track title displayed above the waveform. |
defaultVolume | number | 0.8 | Initial volume 0–1. |
editable | boolean | true | Allow drag-selecting new regions and moving / resizing existing ones. |
regionColor | string | color-mix(in oklab, var(--primary) 25%, transparent) | Fill color of regions. |
activeRegionColor | string | color-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. |
waveColor | string | var(--muted-foreground) | Waveform bar color. Accepts any CSS value including var(--*) tokens. |
progressColor | string | var(--primary) | Progress overlay color. Same formats as waveColor. |
waveHeight | number | 64 | Waveform canvas height in px. |
barWidth | number | 3 | Bar width in px. |
barGap | number | 2 | Gap between bars in px. |
barRadius | number | 2 | Border radius of each bar. |
className | string | — | Extra classes on the outer <Card>. |
RegionData
| Field | Type | Description |
|---|---|---|
id | string | Stable id assigned by wavesurfer. |
start | number | Start time in seconds. |
end | number | End time in seconds. |
color | string | Current fill color. |
content | string | Text content, if any. |