Wave Recorder
A headless-friendly wave recorder with live waveform, built on wavesurfer.js.
Loading...
import React from "react";import { toast } from "sonner";import WaveRecorder from "@/components/wave-recorder";export default function WaveRecorderDemo() { return ( <WaveRecorder onRecordEnd={(blob) => { const url = URL.createObjectURL(blob); toast("Voice message recorded ", { description: ( <pre className="bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4 text-xs"> <code> {JSON.stringify( { size: `${(blob.size / 1024).toFixed(2)} KB`, type: blob.type, url, }, null, 2, )} </code> </pre> ), position: "bottom-right", classNames: { content: "flex flex-col gap-2", }, style: { "--border-radius": "calc(var(--radius) + 4px)", } as React.CSSProperties, }); }} /> );}Installation
Install the required shadcn/ui primitives.
npx shadcn@latest add button skeletonAdd the component via jsrepo.
npx jsrepo add @waves-cn/ui/wave-recorderThis will automatically install wave-cn, wavesurfer.js and lucide-react.
Install the peer dependency.
npm install wavesurfer.jsInstall the required shadcn primitives.
npx shadcn@latest add button skeletonCopy wave-cn.tsx into your project.
/** * A React component for wavesurfer.js * * Usage: * * import WavesurferPlayer from '@wavesurfer/react' * * <WavesurferPlayer * url="/my-server/audio.ogg" * waveColor="purple" * height={100} * onReady={(wavesurfer) => console.log('Ready!', wavesurfer)} * /> */import { useState, useMemo, useEffect, useRef, memo, type ReactElement, type RefObject,} from "react";import WaveSurfer, { type WaveSurferEvents, type WaveSurferOptions,} from "wavesurfer.js";type WavesurferEventHandler<T extends unknown[]> = ( wavesurfer: WaveSurfer, ...args: T) => void;type OnWavesurferEvents = { [K in keyof WaveSurferEvents as `on${Capitalize<K>}`]?: WavesurferEventHandler< WaveSurferEvents[K] >;};type PartialWavesurferOptions = Omit<WaveSurferOptions, "container">;/** * Props for the Wavesurfer component * @public */export type WavesurferProps = PartialWavesurferOptions & OnWavesurferEvents & { className?: string; };/** * Shared waveform defaults applied to every useWavesurfer instance. * Override any of these by passing the corresponding option explicitly. */export const WAVESURFER_DEFAULTS = { waveColor: "var(--muted-foreground)", progressColor: "var(--primary)", height: 64, barWidth: 3, barGap: 2, barRadius: 2, minPxPerSec: 1, cursorWidth: 0,} as const satisfies Partial<WaveSurferOptions>;/** * Use wavesurfer instance */function useWavesurferInstance( containerRef: RefObject<HTMLDivElement | null>, options: Partial<WaveSurferOptions>,): WaveSurfer | null { const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null); // Flatten options object to an array of keys and values to compare them deeply in the hook deps // Exclude plugins from deep comparison since they are mutated during initialization const optionsWithoutPlugins = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { plugins, ...rest } = options; return rest; }, [options]); const flatOptions = useMemo( () => Object.entries(optionsWithoutPlugins).flat(), [optionsWithoutPlugins], ); // Create a wavesurfer instance useEffect(() => { if (!containerRef?.current) return; const ws = WaveSurfer.create({ ...optionsWithoutPlugins, plugins: options.plugins, container: containerRef.current, }); setWavesurfer(ws); return () => { ws.destroy(); }; // Only recreate if plugins array reference changes (not on mutation) // Users should memoize the plugins array to prevent unnecessary re-creation }, [containerRef, options.plugins, ...flatOptions]); return wavesurfer;}/** * Use wavesurfer state */function useWavesurferState(wavesurfer: WaveSurfer | null): { isReady: boolean; isPlaying: boolean; currentTime: number;} { const [isReady, setIsReady] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false); const [hasFinished, setHasFinished] = useState<boolean>(false); const [currentTime, setCurrentTime] = useState<number>(0); useEffect(() => { if (!wavesurfer) return; const unsubscribeFns = [ wavesurfer.on("load", () => { setIsReady(false); setIsPlaying(false); setCurrentTime(0); }), wavesurfer.on("ready", () => { setIsReady(true); setIsPlaying(false); setHasFinished(false); setCurrentTime(0); }), wavesurfer.on("finish", () => { setHasFinished(true); }), wavesurfer.on("play", () => { setIsPlaying(true); }), wavesurfer.on("pause", () => { setIsPlaying(false); }), wavesurfer.on("timeupdate", () => { setCurrentTime(wavesurfer.getCurrentTime()); }), wavesurfer.on("destroy", () => { setIsReady(false); setIsPlaying(false); }), ]; return () => { unsubscribeFns.forEach((fn) => fn()); }; }, [wavesurfer]); return useMemo( () => ({ isReady, isPlaying, hasFinished, currentTime, }), [isPlaying, hasFinished, currentTime, isReady], );}const EVENT_PROP_RE = /^on([A-Z])/;const isEventProp = (key: string) => EVENT_PROP_RE.test(key);const getEventName = (key: string) => key.replace(EVENT_PROP_RE, (_, $1) => $1.toLowerCase(), ) as keyof WaveSurferEvents;/** * Parse props into wavesurfer options and events */function useWavesurferProps( props: WavesurferProps,): [PartialWavesurferOptions, OnWavesurferEvents] { // Props starting with `on` are wavesurfer events, e.g. `onReady` // The rest of the props are wavesurfer options return useMemo<[PartialWavesurferOptions, OnWavesurferEvents]>(() => { const allOptions = { ...props }; const allEvents = { ...props }; for (const key in allOptions) { if (isEventProp(key)) { delete allOptions[key as keyof WavesurferProps]; } else { delete allEvents[key as keyof WavesurferProps]; } } return [allOptions, allEvents]; }, [props]);}/** * Subscribe to wavesurfer events */function useWavesurferEvents( wavesurfer: WaveSurfer | null, events: OnWavesurferEvents,) { const flatEvents = useMemo(() => Object.entries(events).flat(), [events]); // Subscribe to events useEffect(() => { if (!wavesurfer) return; const eventEntries = Object.entries(events); if (!eventEntries.length) return; const unsubscribeFns = eventEntries.map(([name, handler]) => { const event = getEventName(name); return wavesurfer.on(event, (...args) => (handler as WavesurferEventHandler<WaveSurferEvents[typeof event]>)( wavesurfer, ...args, ), ); }); return () => { unsubscribeFns.forEach((fn) => fn()); }; }, [wavesurfer, ...flatEvents]);}/** * Wavesurfer player component * @see https://wavesurfer.xyz/docs/modules/wavesurfer * @public */const WavesurferPlayer = memo((props: WavesurferProps): ReactElement => { const containerRef = useRef<HTMLDivElement | null>(null); const { className, ...propsWithoutClassName } = props; const [rawOptions, events] = useWavesurferProps(propsWithoutClassName); // Apply WAVESURFER_DEFAULTS and resolve CSS var() tokens — same logic as // useWavesurfer — so WavesurferPlayer benefits from shared defaults too. const waveColor = (rawOptions.waveColor as string | undefined) ?? WAVESURFER_DEFAULTS.waveColor; const progressColor = (rawOptions.progressColor as string | undefined) ?? WAVESURFER_DEFAULTS.progressColor; const resolvedWaveColor = useCssVar(waveColor); const resolvedProgressColor = useCssVar(progressColor); const options = useMemo(() => { const { waveColor: _wc, progressColor: _pc, ...rest } = rawOptions; const cleanOptions = Object.fromEntries( Object.entries(rest).filter(([, v]) => v !== undefined), ) as PartialWavesurferOptions; return { ...WAVESURFER_DEFAULTS, ...cleanOptions, waveColor: resolvedWaveColor, progressColor: resolvedProgressColor, } as PartialWavesurferOptions; // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(rawOptions)]); const wavesurfer = useWavesurferInstance(containerRef, options); useWavesurferEvents(wavesurfer, events); // Create a container div return <div ref={containerRef} className={className} />;});/** * @public */export default WavesurferPlayer;/** * React hook for wavesurfer.js * * Automatically applies shared defaults (colors, height, bar shape, volume). * Any option passed explicitly will override the defaults. * CSS var() tokens in waveColor and progressColor are resolved at runtime * so they work correctly with the Canvas API. * * ``` * import { useWavesurfer } from '@/components/cors/wavesurfer-player' * * const App = () => { * const containerRef = useRef<HTMLDivElement | null>(null) * * const { wavesurfer, isReady, isPlaying, currentTime } = useWavesurfer({ * container: containerRef, * url: '/my-server/audio.ogg', * }) * * return <div ref={containerRef} /> * } * ``` * * @public */export function useWavesurfer({ container, waveColor = WAVESURFER_DEFAULTS.waveColor, progressColor = WAVESURFER_DEFAULTS.progressColor, ...options}: Omit<WaveSurferOptions, "container"> & { container: RefObject<HTMLDivElement | null>;}): ReturnType<typeof useWavesurferState> & { wavesurfer: ReturnType<typeof useWavesurferInstance>;} { // Resolve CSS var() tokens so the Canvas API receives actual color values const resolvedWaveColor = useCssVar(waveColor as string); const resolvedProgressColor = useCssVar(progressColor as string); // Memoize mergedOptions so useWavesurferInstance gets a stable object reference. // Without this, a new object is created every render → flatOptions changes → // the instance is destroyed and recreated on every render, wiping spread props. // Also strip undefined values so they don't silently override WAVESURFER_DEFAULTS. const mergedOptions = useMemo(() => { const cleanOptions = Object.fromEntries( Object.entries(options).filter(([, v]) => v !== undefined), ) as Partial<WaveSurferOptions>; return { ...WAVESURFER_DEFAULTS, ...cleanOptions, waveColor: resolvedWaveColor, progressColor: resolvedProgressColor, } as Partial<WaveSurferOptions>; // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(options)]); const wavesurfer = useWavesurferInstance(container, mergedOptions); const state = useWavesurferState(wavesurfer); return useMemo(() => ({ ...state, wavesurfer }), [state, wavesurfer]);}export function useCssVar(value: string): string { const [resolved, setResolved] = useState(value); useEffect(() => { // Only resolve if the value looks like a CSS variable const match = value.match(/^var\((--[^)]+)\)$/); if (!match) { setResolved(value); return; } const varName = match[1]; const raw = getComputedStyle(document.documentElement) .getPropertyValue(varName) .trim(); // shadcn stores values as bare HSL channels e.g. "222.2 47.4% 11.2%" // Canvas needs a full color string — wrap in hsl() if needed const isHslChannels = /^[\d.]+ [\d.]+% [\d.]+%$/.test(raw); setResolved(raw ? (isHslChannels ? `hsl(${raw})` : raw) : value); }, [value]); return resolved;}Add formatDuration utility to your project.
export function formatDuration(duration: number) {
const formattedTime = [
Math.floor((duration % 3600000) / 60000),
Math.floor((duration % 60000) / 1000),
]
.map((v) => (v < 10 ? "0" + v : v))
.join(":")
return formattedTime
}Copy wave-recorder.tsx into your project.
import { useRef, useMemo, useState, useEffect, useCallback, type CSSProperties,} from "react";import { Button } from "@/components/ui/button";import { Mic, Pause, Play, Square, Trash2 } from "lucide-react";import RecordPlugin, { type RecordPluginOptions, type RecordPluginDeviceOptions,} from "wavesurfer.js/dist/plugins/record.esm.js";import { cn, formatDuration } from "@/lib/utils";import { useCssVar } from "@/hooks/use-css-var";import { useWavesurfer } from "@/lib/wave-cn";// Typesexport type RecordState = "idle" | "recording" | "paused" | "done";export type WaveRecorderProps = { // Callbacks onRecordEnd?: (blob: Blob) => void; onRecordStart?: () => void; onRecordPause?: () => void; onRecordResume?: () => void; onDiscard?: () => void; onError?: (error: Error) => void; // Behaviour maxDuration?: number; mimeType?: RecordPluginOptions["mimeType"]; audioBitsPerSecond?: RecordPluginOptions["audioBitsPerSecond"]; deviceId?: string; disabled?: boolean; // Display showWaveform?: boolean; // default: true showTimer?: boolean; // default: true waveColor?: string; progressColor?: string; waveformHeight?: number; // default: 64 barWidth?: number; // default: 3 barGap?: number; // default: 2 barRadius?: number; // default: 30 barHeight?: number; // default: 0.8 // Style className?: string; style?: CSSProperties; waveformClassName?: string; timerClassName?: string; controlsClassName?: string;};export function WaveRecorder({ onRecordEnd, onRecordStart, onRecordPause, onRecordResume, onDiscard, onError, maxDuration, mimeType, audioBitsPerSecond = 1, deviceId, disabled = false, showWaveform = true, showTimer = true, waveColor = "var(--primary)", progressColor = "var(--background)", waveformHeight = 64, barWidth = 3, barGap = 2, barRadius = 2, barHeight, className, style, waveformClassName, timerClassName, controlsClassName,}: WaveRecorderProps) { const containerRef = useRef<HTMLDivElement | null>(null); const [recordPlugin, setRecordPlugin] = useState<InstanceType< typeof RecordPlugin > | null>(null); const [recordState, setRecordState] = useState<RecordState>("idle"); const [duration, setDuration] = useState(0); const isDiscarding = useRef(false); const maxDurationTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const resolvedWaveColor = useCssVar(waveColor); const resolvedProgressColor = useCssVar(progressColor); const { wavesurfer } = useWavesurfer({ container: containerRef, waveColor: resolvedWaveColor, progressColor: resolvedProgressColor, height: waveformHeight, barWidth, barGap, barRadius, barHeight, }); // Plugin events useEffect(() => { if (!wavesurfer) return; wavesurfer.registerPlugin( RecordPlugin.create({ renderRecordedAudio: false, continuousWaveform: false, scrollingWaveform: true, mimeType, audioBitsPerSecond, mediaRecorderTimeslice: 100, }), ); const record = wavesurfer .getActivePlugins() .find((p) => p instanceof RecordPlugin) as | InstanceType<typeof RecordPlugin> | undefined; if (!record) return; setRecordPlugin(record); const unsubs = [ record.on("record-progress", (ms) => setDuration(ms)), record.on("record-start", () => { setRecordState("recording"); setDuration(0); onRecordStart?.(); }), record.on("record-pause", () => { setRecordState("paused"); onRecordPause?.(); }), record.on("record-resume", () => { setRecordState("recording"); onRecordResume?.(); }), record.on("record-end", (blob: Blob) => { if (maxDurationTimer.current) { clearTimeout(maxDurationTimer.current); maxDurationTimer.current = null; } if (!isDiscarding.current) { onRecordEnd?.(blob); setRecordState("done"); } else { onDiscard?.(); setRecordState("idle"); } isDiscarding.current = false; wavesurfer.empty(); setDuration(0); }), ]; return () => unsubs.forEach((fn) => fn()); }, [wavesurfer]); // Actions const start = useCallback(async () => { if (!recordPlugin || disabled) return; try { const deviceOptions: RecordPluginDeviceOptions = deviceId ? { deviceId: { exact: deviceId } } : {}; await recordPlugin.startRecording(deviceOptions); if (maxDuration && maxDuration > 0) { maxDurationTimer.current = setTimeout( () => recordPlugin.stopRecording(), maxDuration * 1000, ); } } catch (err) { onError?.(err instanceof Error ? err : new Error(String(err))); } }, [recordPlugin, disabled, deviceId, maxDuration, onError]); const stop = useCallback(() => { if (maxDurationTimer.current) { clearTimeout(maxDurationTimer.current); maxDurationTimer.current = null; } recordPlugin?.stopRecording(); }, [recordPlugin]); const togglePause = useCallback(() => { if (!recordPlugin) return; recordState === "paused" ? recordPlugin.resumeRecording() : recordPlugin.pauseRecording(); }, [recordPlugin, recordState]); const discard = useCallback(() => { if (maxDurationTimer.current) { clearTimeout(maxDurationTimer.current); maxDurationTimer.current = null; } isDiscarding.current = true; recordPlugin?.stopRecording(); wavesurfer?.empty(); setRecordState("idle"); setDuration(0); }, [recordPlugin, wavesurfer]); // Derived const isActive = recordState === "recording" || recordState === "paused"; const isPaused = recordState === "paused"; // Render return ( <div className={cn("w-full space-y-3 ", className)} style={style}> {/* Waveform — always mounted for stable DOM ref, hidden when idle */} <div className={cn( "flex items-center w-full ", (!showWaveform && !showTimer) || !isActive ? "hidden" : "opacity-100", )} > <div ref={containerRef} aria-hidden="true" className={cn( "overflow-hidden transition-all duration-300 w-full", showWaveform && isActive ? "opacity-100 mb-2" : "opacity-0 h-0 pointer-events-none", waveformClassName, )} style={ showWaveform && isActive ? { height: waveformHeight } : undefined } /> </div> {/* Controls */} {showTimer && isActive && ( <p className={cn( "text-base tabular-nums text-muted-foreground shrink-0 text-center", timerClassName, )} > {formatDuration(duration)} </p> )} <div className={cn( "flex items-center gap-2 justify-center", controlsClassName, )} > {!isActive && ( <Button size="icon" variant="secondary" onClick={start} disabled={disabled} aria-label="Start recording" > <Mic className="size-4" /> </Button> )} {isActive && ( <> <Button size="icon" variant="outline" onClick={discard} disabled={disabled} aria-label="Discard recording" className="hover:text-destructive hover:bg-destructive/10" > <Trash2 className="size-4" /> </Button> <Button size="icon" variant="destructive" onClick={stop} disabled={disabled} aria-label="Stop recording" > <Square className="size-4" /> </Button> <Button size="icon" variant="outline" onClick={togglePause} disabled={disabled} aria-label={isPaused ? "Resume recording" : "Pause recording"} > {isPaused ? ( <Play className="size-4" /> ) : ( <Pause className="size-4" /> )} </Button> </> )} </div> </div> );}export default WaveRecorder;Update the import paths to match your project setup.
Features
- Live waveform visualization — a scrolling waveform rendered on a Canvas via wavesurfer.js updates in real time while recording, then hides itself at rest.
- Pause & resume — pause mid-recording and continue without losing audio; the timer and waveform stay in sync throughout.
- Auto-stop — pass
maxDuration(in seconds) to automatically stop recording and emit the blob once the limit is reached. - Discard — lets the user throw away the current take; triggers
onDiscardinstead ofonRecordEndso unwanted audio is never processed. - Custom waveform styling —
waveColor,progressColor,barWidth,barGap,barRadius, andbarHeightall accept any CSS value includingvar(--*)tokens, resolved at runtime for the Canvas API. - Plugin-ready —
RecordPluginis injected intoWavesurferPlayer(oruseWavesurfer) viaonReady, so any other wavesurfer.js plugin can be added the same way without touching the core.
Usage
import { WaveRecorder } from "@/components/ui/wave-recorder"<WaveRecorder onRecordEnd={(blob) => console.log(blob)} />Examples
Minimal
Loading...
import React from "react";import WaveRecorder from "@/components/wave-recorder";import { toast } from "sonner";export default function WaveRecorderMinimal() { return ( <WaveRecorder showWaveform={false} showTimer={false} onRecordEnd={(blob) => { const url = URL.createObjectURL(blob); toast("Voice message recorded ", { description: ( <pre className="bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4 text-xs"> <code> {JSON.stringify( { size: `${(blob.size / 1024).toFixed(2)} KB`, type: blob.type, url, }, null, 2, )} </code> </pre> ), position: "bottom-right", classNames: { content: "flex flex-col gap-2", }, style: { "--border-radius": "calc(var(--radius) + 4px)", } as React.CSSProperties, }); }} /> );}Max Duration
Auto-stop after N seconds using maxDuration.
Loading...
import React from "react";import WaveRecorder from "@/components/wave-recorder";import { toast } from "sonner";export default function WaveRecorderTimed() { return ( <WaveRecorder maxDuration={10} onRecordEnd={(blob) => { const url = URL.createObjectURL(blob); toast("Voice message recorded ", { description: ( <pre className="bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4 text-xs"> <code> {JSON.stringify( { size: `${(blob.size / 1024).toFixed(2)} KB`, type: blob.type, url, }, null, 2, )} </code> </pre> ), position: "bottom-right", classNames: { content: "flex flex-col gap-2", }, style: { "--border-radius": "calc(var(--radius) + 4px)", } as React.CSSProperties, }); }} /> );}<WaveRecorder
maxDuration={30}
onRecordEnd={handleBlob}
/>Custom Waveform
waveColor and progressColor accept any CSS value including var(--*) tokens — resolved at runtime for the Canvas API.
Loading...
import React from "react";import WaveRecorder from "@/components/wave-recorder";import { toast } from "sonner";export default function WaveRecorderCustomWave() { return ( <WaveRecorder waveColor="#7c3aed" progressColor="#ffffff" waveformHeight={80} barWidth={4} barGap={2} barRadius={8} barHeight={0.9} className="rounded-xl border p-4 bg-muted/40" onRecordEnd={(blob) => { const url = URL.createObjectURL(blob); toast("Voice message recorded ", { description: ( <pre className="bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4 text-xs"> <code> {JSON.stringify( { size: `${(blob.size / 1024).toFixed(2)} KB`, type: blob.type, url, }, null, 2, )} </code> </pre> ), position: "bottom-right", classNames: { content: "flex flex-col gap-2", }, style: { "--border-radius": "calc(var(--radius) + 4px)", } as React.CSSProperties, }); }} /> );}<WaveRecorder
waveColor="var(--chart-1)"
progressColor="var(--chart-2)"
barWidth={4}
barGap={3}
barRadius={8}
onRecordEnd={handleBlob}
/>API Reference
WaveRecorder
| Prop | Type | Default | Description |
|---|---|---|---|
onRecordEnd | (blob: Blob) => void | — | Fires on successful stop. Not fired on discard. |
onRecordStart | () => void | — | Fires when recording starts. |
onRecordPause | () => void | — | Fires when recording is paused. |
onRecordResume | () => void | — | Fires when recording resumes. |
onDiscard | () => void | — | Fires when user discards. |
onError | (error: Error) => void | — | Fires on mic / MediaRecorder error. |
maxDuration | number | — | Auto-stop after N seconds. |
mimeType | string | browser default | e.g. "audio/webm;codecs=opus" |
audioBitsPerSecond | number | — | Bitrate passed to MediaRecorder. |
deviceId | string | — | Specific mic device ID to use. |
disabled | boolean | false | Disables all controls. |
showWaveform | boolean | true | Show live waveform during recording. |
showTimer | boolean | true | Show duration counter. |
waveColor | string | var(--primary) | Accepts CSS vars, hex, hsl, oklch. |
progressColor | string | var(--primary-foreground) | Same formats as waveColor. |
waveformHeight | number | 64 | Waveform height in px. |
barWidth | number | 3 | Bar width in px. |
barGap | number | 2 | Gap between bars in px. |
barRadius | number | 2 | Bar border radius in px. |
barHeight | number | 0.8 | Bar height multiplier (0–2). |
className | string | — | Root element class. |
waveformClassName | string | — | Waveform container class. |
timerClassName | string | — | Timer element class. |
controlsClassName | string | — | Controls row class. |