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 skeleton

Add the component via jsrepo.

npx jsrepo add @waves-cn/ui/wave-recorder

This will automatically install wave-cn, wavesurfer.js and lucide-react.

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.

/** * 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.

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 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 onDiscard instead of onRecordEnd so unwanted audio 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/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

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