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 {  useState,  useEffect,  useRef,  memo,  type ReactElement,  type RefObject,} from "react";import WaveSurfer, {  type WaveSurferEvents,  type WaveSurferOptions,} from "wavesurfer.js";type WavesurferEventHandler<T extends unknown[]> = (  wavesurfer: WaveSurfer,  ...args: T) => void;type OnWavesurferEvents = {  [K in keyof WaveSurferEvents as `on${Capitalize<K>}`]?: WavesurferEventHandler<    WaveSurferEvents[K]  >;};type PartialWavesurferOptions = Omit<WaveSurferOptions, "container">;export type WavesurferProps = PartialWavesurferOptions &  OnWavesurferEvents & {    className?: string;  };export const WAVESURFER_DEFAULTS = {  waveColor: "var(--muted-foreground)",  progressColor: "var(--primary)",  height: 64,  barWidth: 3,  barGap: 2,  barRadius: 2,  minPxPerSec: 1,  cursorWidth: 0,} as const satisfies Partial<WaveSurferOptions>;const EVENT_PROP_RE = /^on([A-Z])/;const isEventProp = (key: string) => EVENT_PROP_RE.test(key);const getEventName = (key: string) =>  key.replace(EVENT_PROP_RE, (_, $1) =>    $1.toLowerCase(),  ) as keyof WaveSurferEvents;// ─── Component ───────────────────────────────────────────────────────────────const WavesurferPlayer = memo(  (props: WavesurferProps): ReactElement => {    const containerRef = useRef<HTMLDivElement | null>(null);    const wsRef = useRef<WaveSurfer | null>(null);    const { className, ...rest } = props;    // ── Separate options from event handlers    const options: Partial<WaveSurferOptions> = {};    const eventProps: OnWavesurferEvents = {};    for (const key in rest) {      if (isEventProp(key))        eventProps[key as keyof OnWavesurferEvents] = rest[          key as keyof typeof rest        ] as never;      else        options[key as keyof PartialWavesurferOptions] = rest[          key as keyof typeof rest        ] as never;    }    // ── Resolve CSS vars    const waveColor =      (options.waveColor as string | undefined) ??      WAVESURFER_DEFAULTS.waveColor;    const progressColor =      (options.progressColor as string | undefined) ??      WAVESURFER_DEFAULTS.progressColor;    const resolvedWaveColor = useCssVar(waveColor);    const resolvedProgressColor = useCssVar(progressColor);    // ── Keep event handlers in a ref — changes never cause re-subscription    const eventsRef = useRef(eventProps);    eventsRef.current = eventProps;    // ── Keep non-url options in a ref — changes applied imperatively    const optionsRef = useRef(options);    optionsRef.current = options;    // ── Create instance only when url or structural options change    const url = options.url as string | undefined;    const height =      (options.height as number | undefined) ?? WAVESURFER_DEFAULTS.height;    const barWidth =      (options.barWidth as number | undefined) ?? WAVESURFER_DEFAULTS.barWidth;    const barGap =      (options.barGap as number | undefined) ?? WAVESURFER_DEFAULTS.barGap;    const barRadius =      (options.barRadius as number | undefined) ??      WAVESURFER_DEFAULTS.barRadius;    const minPxPerSec =      (options.minPxPerSec as number | undefined) ??      WAVESURFER_DEFAULTS.minPxPerSec;    const cursorWidth =      (options.cursorWidth as number | undefined) ??      WAVESURFER_DEFAULTS.cursorWidth;    const dragToSeek = options.dragToSeek as boolean | undefined;    const media = options.media as HTMLMediaElement | undefined;    useEffect(() => {      if (!containerRef.current) return;      const ws = WaveSurfer.create({        ...WAVESURFER_DEFAULTS,        url,        height,        barWidth,        barGap,        barRadius,        minPxPerSec,        cursorWidth,        dragToSeek,        media,        plugins: optionsRef.current.plugins,        waveColor: resolvedWaveColor,        progressColor: resolvedProgressColor,        container: containerRef.current,      });      wsRef.current = ws;      // Subscribe to all events via ref — always calls latest handler      const eventEntries = Object.keys(eventsRef.current);      const unsubs = eventEntries.map((name) => {        const event = getEventName(name);        return ws.on(event, (...args) =>          (            eventsRef.current[              name as keyof OnWavesurferEvents            ] as WavesurferEventHandler<WaveSurferEvents[typeof event]>          )?.(ws, ...args),        );      });      return () => {        unsubs.forEach((fn) => fn());        ws.destroy();        wsRef.current = null;      };      // Only remount when these primitive options change — NOT handlers, NOT colors      // eslint-disable-next-line react-hooks/exhaustive-deps    }, [      url,      height,      barWidth,      barGap,      barRadius,      minPxPerSec,      cursorWidth,      dragToSeek,    ]);    // ── Apply color changes imperatively — zero remount on theme switch    useEffect(() => {      wsRef.current?.setOptions({        waveColor: resolvedWaveColor,        progressColor: resolvedProgressColor,      });    }, [resolvedWaveColor, resolvedProgressColor]);    // ── Skeleton    const [isReady, setIsReady] = useState(false);    useEffect(() => {      const ws = wsRef.current;      if (!ws) return;      // Sync immediately with current instance — avoids skeleton flash on re-render      // when the instance already exists and audio is already decoded      setIsReady(ws.getDuration() > 0);      const unsubs = [        ws.on("ready", () => setIsReady(true)),        ws.on("load", () => setIsReady(false)),        ws.on("destroy", () => setIsReady(false)),      ];      return () => unsubs.forEach((fn) => fn());      // Re-attach when instance changes (url change creates new instance)      // eslint-disable-next-line react-hooks/exhaustive-deps    }, [wsRef.current]);    return (      <div className={className} style={{ position: "relative" }}>        {!isReady && (          <div            style={{              height,              width: "100%",              position: "absolute",              inset: 0,              borderRadius: 4,              background: "hsl(var(--muted))",              animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",            }}          />        )}        <div ref={containerRef} style={!isReady ? { opacity: 0 } : undefined} />      </div>    );  },  (prev, next) => {    // Only remount when structural audio options change — ignore handlers and className    const STRUCTURAL_KEYS = [      "url",      "height",      "barWidth",      "barGap",      "barRadius",      "minPxPerSec",      "cursorWidth",      "dragToSeek",      "waveColor",      "progressColor",    ];    return STRUCTURAL_KEYS.every(      (k) =>        prev[k as keyof WavesurferProps] === next[k as keyof WavesurferProps],    );  },);export default WavesurferPlayer;// ─── Hook ────────────────────────────────────────────────────────────────────export function useWavesurfer({  container,  waveColor = WAVESURFER_DEFAULTS.waveColor,  progressColor = WAVESURFER_DEFAULTS.progressColor,  ...options}: Omit<WaveSurferOptions, "container"> & {  container: RefObject<HTMLDivElement | null>;}) {  const resolvedWaveColor = useCssVar(waveColor as string);  const resolvedProgressColor = useCssVar(progressColor as string);  const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);  const [isReady, setIsReady] = useState(false);  const [isPlaying, setIsPlaying] = useState(false);  const [currentTime, setCurrentTime] = useState(0);  const url = options.url as string | undefined;  const height =    (options.height as number | undefined) ?? WAVESURFER_DEFAULTS.height;  const barWidth =    (options.barWidth as number | undefined) ?? WAVESURFER_DEFAULTS.barWidth;  const barGap =    (options.barGap as number | undefined) ?? WAVESURFER_DEFAULTS.barGap;  const barRadius =    (options.barRadius as number | undefined) ?? WAVESURFER_DEFAULTS.barRadius;  const minPxPerSec =    (options.minPxPerSec as number | undefined) ??    WAVESURFER_DEFAULTS.minPxPerSec;  useEffect(() => {    if (!container.current) return;    const ws = WaveSurfer.create({      ...WAVESURFER_DEFAULTS,      ...options,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,      container: container.current,    });    setWavesurfer(ws);    const unsubs = [      ws.on("load", () => {        setIsReady(false);        setIsPlaying(false);        setCurrentTime(0);      }),      ws.on("ready", () => {        setIsReady(true);      }),      ws.on("play", () => {        setIsPlaying(true);      }),      ws.on("pause", () => {        setIsPlaying(false);      }),      ws.on("timeupdate", () => {        setCurrentTime(ws.getCurrentTime());      }),      ws.on("destroy", () => {        setIsReady(false);        setIsPlaying(false);      }),    ];    return () => {      unsubs.forEach((fn) => fn());      ws.destroy();    };    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [url, height, barWidth, barGap, barRadius, minPxPerSec]);  useEffect(() => {    wavesurfer?.setOptions({      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    });  }, [wavesurfer, resolvedWaveColor, resolvedProgressColor]);  return { wavesurfer, isReady, isPlaying, currentTime };}// ─── CSS var resolver ────────────────────────────────────────────────────────export function useCssVar(value: string): string {  const [resolved, setResolved] = useState(value);  useEffect(() => {    const match = value.match(/^var\((--[^)]+)\)$/);    if (!match) {      setResolved(value);      return;    }    const varName = match[1];    const resolve = () => {      const raw = getComputedStyle(document.documentElement)        .getPropertyValue(varName)        .trim();      const isHsl = /^[\d.]+ [\d.]+% [\d.]+%$/.test(raw);      setResolved(raw ? (isHsl ? `hsl(${raw})` : raw) : value);    };    resolve();    const observer = new MutationObserver(resolve);    observer.observe(document.documentElement, {      attributes: true,      attributeFilter: ["class", "style", "data-theme"],    });    return () => observer.disconnect();  }, [value]);  return resolved;}

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,  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 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 stylingwaveColor, progressColor, barWidth, barGap, barRadius, and barHeight all accept any CSS value including var(--*) tokens, resolved at runtime for the Canvas API.
  • Plugin-readyRecordPlugin 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

WaveRecorder

PropTypeDefaultDescription
onRecordEnd(blob: Blob) => voidFires on successful stop. Not fired on discard.
onRecordStart() => voidFires when recording starts.
onRecordPause() => voidFires when recording is paused.
onRecordResume() => voidFires when recording resumes.
onDiscard() => voidFires when user discards.
onError(error: Error) => voidFires on mic / MediaRecorder error.
maxDurationnumberAuto-stop after N seconds.
mimeTypestringbrowser defaulte.g. "audio/webm;codecs=opus"
audioBitsPerSecondnumberBitrate passed to MediaRecorder.
deviceIdstringSpecific mic device ID to use.
disabledbooleanfalseDisables all controls.
showWaveformbooleantrueShow live waveform during recording.
showTimerbooleantrueShow duration counter.
waveColorstringvar(--primary)Accepts CSS vars, hex, hsl, oklch.
progressColorstringvar(--primary-foreground)Same formats as waveColor.
waveformHeightnumber64Waveform height in px.
barWidthnumber3Bar width in px.
barGapnumber2Gap between bars in px.
barRadiusnumber2Bar border radius in px.
barHeightnumber0.8Bar height multiplier (0–2).
classNamestringRoot element class.
waveformClassNamestringWaveform container class.
timerClassNamestringTimer element class.
controlsClassNamestringControls row class.

On this page