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/waves-cn/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

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

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

npx shadcn@latest add button 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,  };}

Add formatDuration utility to your project.

utils.ts
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,  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 } from "@/lib/utils";import { formatTime, useCssVar, 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"]; // default: 128000  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;};type RecordPluginInstance = InstanceType<typeof RecordPlugin>;export function WaveRecorder({  onRecordEnd,  onRecordStart,  onRecordPause,  onRecordResume,  onDiscard,  onError,  maxDuration,  mimeType,  audioBitsPerSecond = 128000,  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);  // Live plugin instance. Kept in a ref (not state): it is only read from  // event handlers, and the plugin effect must not call setState synchronously.  const recordRef = useRef<RecordPluginInstance | 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);  // Latest callback props, read at event time so the plugin effect never  // needs to re-run when a parent passes a new inline function.  const callbacks = useRef({    onRecordEnd,    onRecordStart,    onRecordPause,    onRecordResume,    onDiscard,  });  useEffect(() => {    callbacks.current = {      onRecordEnd,      onRecordStart,      onRecordPause,      onRecordResume,      onDiscard,    };  });  const resolvedWaveColor = useCssVar(waveColor);  const resolvedProgressColor = useCssVar(progressColor);  const { wavesurfer } = useWavesurfer({    container: containerRef,    waveColor: resolvedWaveColor,    progressColor: resolvedProgressColor,    height: waveformHeight,    barWidth,    barGap,    barRadius,    barHeight,  });  const clearMaxDurationTimer = useCallback(() => {    if (maxDurationTimer.current) {      clearTimeout(maxDurationTimer.current);      maxDurationTimer.current = null;    }  }, []);  // Plugin lifecycle + events  useEffect(() => {    if (!wavesurfer) return;    const record = wavesurfer.registerPlugin(      RecordPlugin.create({        renderRecordedAudio: false,        continuousWaveform: false,        scrollingWaveform: true,        mimeType,        audioBitsPerSecond,        mediaRecorderTimeslice: 100,      }),    );    recordRef.current = record;    const unsubs = [      // Only re-render when the displayed second changes.      record.on("record-progress", (ms) => {        setDuration((prev) =>          Math.floor(ms / 1000) === Math.floor(prev / 1000) ? prev : ms,        );      }),      record.on("record-start", () => {        setRecordState("recording");        setDuration(0);        callbacks.current.onRecordStart?.();      }),      record.on("record-pause", () => {        setRecordState("paused");        callbacks.current.onRecordPause?.();      }),      record.on("record-resume", () => {        setRecordState("recording");        callbacks.current.onRecordResume?.();      }),      record.on("record-end", (blob: Blob) => {        if (maxDurationTimer.current) {          clearTimeout(maxDurationTimer.current);          maxDurationTimer.current = null;        }        if (!isDiscarding.current) {          callbacks.current.onRecordEnd?.(blob);          setRecordState("done");        } else {          callbacks.current.onDiscard?.();          setRecordState("idle");        }        isDiscarding.current = false;        wavesurfer.empty();        setDuration(0);      }),    ];    return () => {      unsubs.forEach((fn) => fn());      if (maxDurationTimer.current) {        clearTimeout(maxDurationTimer.current);        maxDurationTimer.current = null;      }      if (record.isActive()) record.stopRecording();      record.stopMic();      record.destroy();      if (recordRef.current === record) recordRef.current = null;    };  }, [wavesurfer, mimeType, audioBitsPerSecond]);  // Actions  const start = useCallback(async () => {    const record = recordRef.current;    if (!record || disabled) return;    try {      const deviceOptions: RecordPluginDeviceOptions = deviceId        ? { deviceId: { exact: deviceId } }        : {};      await record.startRecording(deviceOptions);      if (maxDuration && maxDuration > 0) {        maxDurationTimer.current = setTimeout(          () => record.stopRecording(),          maxDuration * 1000,        );      }    } catch (err) {      onError?.(err instanceof Error ? err : new Error(String(err)));    }  }, [disabled, deviceId, maxDuration, onError]);  const stop = useCallback(() => {    clearMaxDurationTimer();    recordRef.current?.stopRecording();  }, [clearMaxDurationTimer]);  const togglePause = useCallback(() => {    const record = recordRef.current;    if (!record) return;    if (recordState === "paused") {      record.resumeRecording();    } else {      record.pauseRecording();    }  }, [recordState]);  const discard = useCallback(() => {    clearMaxDurationTimer();    isDiscarding.current = true;    recordRef.current?.stopRecording();    wavesurfer?.empty();    setRecordState("idle");    setDuration(0);  }, [clearMaxDurationTimer, 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,          )}        >          {formatTime(duration / 1000)}        </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 wave; 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 onDiscard instead of onRecordEnd so unwanted wave is never processed.
  • Custom waveform styling — waveColor, progressColor, barWidth, barGap, barRadius, and barHeight all accept any CSS value including var(--*) tokens, resolved at runtime for the Canvas API.
  • Plugin-ready — RecordPlugin is injected into WavesurferPlayer (or useWavesurfer) via onReady, so any other wavesurfer.js plugin can be added the same way without touching the core.

Usage

import { WaveRecorder } from "@/components/waves-cn/wave-recorder"
<WaveRecorder onRecordEnd={(blob) => console.log(blob)} />

Examples

Minimal

Loading...
import React from "react";import WaveRecorder from "@/components/waves-cn/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/waves-cn/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,        });      }}    />  );}

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/waves-cn/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,        });      }}    />  );}

API Reference

PropTypeDefaultDescription
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 the user discards the take.
onError(error: Error) => void—Fires on microphone / MediaRecorder errors.
maxDurationnumber—Auto-stop after N seconds.
mimeTypestringbrowser defaulte.g. "audio/webm;codecs=opus".
audioBitsPerSecondnumber128000Bitrate passed to MediaRecorder.
deviceIdstring—Specific microphone device id.
disabledbooleanfalseDisables all controls.
showWaveformbooleantrueShow the live waveform while recording.
showTimerbooleantrueShow the elapsed-time counter.
waveColorstringvar(--primary)Live waveform color.
progressColorstringvar(--background)Progress color of the live waveform.
waveformHeightnumber64Waveform height in px.
barWidthnumber3Bar width in px.
barGapnumber2Gap between bars in px.
barRadiusnumber2Bar border radius in px.
barHeightnumber—Bar height multiplier passed to wavesurfer.
classNamestring—Root element class.
styleCSSProperties—Root element inline style.
waveformClassNamestring—Waveform container class.
timerClassNamestring—Timer element class.
controlsClassNamestring—Controls row class.

The microphone stream and the record plugin are released when the component unmounts, including mid-recording.

On this page