Components
AudioRecorder
Voice recorder with realtime waveform, duration, cancel, and submit controls.
Examples
Line waveform version
The recorder hosts any AudioVisualizer usage version. App-wide, repoint the one import in your owned audio-recorder.tsx (bars → line); per instance, compose explicit children and stand a part bound to the line version on useAudioRecorderContext, as here.
Composition
Anatomy
- <AudioRecorder>
- <AudioRecorderTrigger />
- <AudioRecorderStatus />
- <AudioRecorderVisualizer />
- <AudioRecorderDuration />
- <AudioRecorderCancel />
- <AudioRecorderSubmit />
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
This agent installs from the audio-recorder registry. Install the bundle with the command above, or inspect the source below.
npx shadcn@latest add https://control-ui.dev/r/audio-recorder.jsonUsage
"use client";import { useChat, useMastraClient } from "@mastra/react";import { AudioRecorder, type AudioRecording } from "@/components/control-ui/audio-recorder";export function Example({ agentId }: { agentId: string }) { const client = useMastraClient(); const { sendMessage } = useChat({ agentId }); async function handleRecordingComplete(recording: AudioRecording) { const { text } = await client.getAgent(agentId).voice.listen(recording.blob); await sendMessage({ message: text }); } return <AudioRecorder onRecordingComplete={handleRecordingComplete} />;}Dependencies
Behavior hook
src/registry/hooks/use-audio-recorder.tsHookDuration formatter
AudioVisualizersrc/registry/lib/format-audio-recorder-duration.tsSupportaudio-visualizerComponentButtonbuttonPrimitiveRaw code
This agent’s owned source and private support files
"use client";import { CheckIcon, MicIcon, XIcon } from "lucide-react";import type { ComponentProps, CSSProperties, MouseEvent } from "react";import { createContext, use } from "react";// point this at ./audio-visualizer-line for line reading — same export, same contract, no call site movesimport { AudioVisualizer } from "@/components/control-ui/audio-visualizer";import type { UseAudioRecorderOptions, UseAudioRecorderResult } from "@/components/control-ui/hooks/use-audio-recorder";import { useAudioRecorder } from "@/components/control-ui/hooks/use-audio-recorder";import type { AudioRecorderKnobStyle } from "@/components/control-ui/knob-contracts/audio-recorder-knobs";import type { AudioVisualizerKnobStyle } from "@/components/control-ui/knob-contracts/audio-visualizer-knobs";import type { ButtonKnobStyle } from "@/components/control-ui/knob-contracts/button-knobs";import { cn } from "@/components/control-ui/lib/cn";import { formatAudioRecorderDuration } from "@/components/control-ui/lib/format-audio-recorder-duration";import { Button } from "@/components/control-ui/ui/button";type AudioRecorderStyleProps<Props, Style> = Omit<Props, "style"> & { style?: CSSProperties & Style;};const AudioRecorderContext = createContext<UseAudioRecorderResult | null>(null);// lets call site stand its own part inside <AudioRecorder>, e.g. different visualizer per instanceexport function useAudioRecorderContext() { const context = use(AudioRecorderContext); if (!context) throw new Error("AudioRecorder compound components must be rendered inside <AudioRecorder>."); return context;}export type { AudioRecording } from "@/components/control-ui/hooks/use-audio-recorder";// no bundled device picker — pair `deviceId` with useAudioInputDevices and any list UIexport type AudioRecorderProps = Omit<ComponentProps<"div">, "style"> & UseAudioRecorderOptions & { label?: string; style?: CSSProperties & AudioRecorderKnobStyle; };export function AudioRecorder({ onRecordingComplete, onCancel, maxDurationMs, disabled, barCount, barDurationMs, deviceId, label = "Voice recorder", className, children, ...props}: AudioRecorderProps) { const recorder = useAudioRecorder({ onRecordingComplete, onCancel, maxDurationMs, disabled, barCount, barDurationMs, deviceId }); return ( <AudioRecorderContext.Provider value={recorder}> {/* biome-ignore lint/a11y/useSemanticElements: toolbar-like group, not a form fieldset */} <div data-control-ui="audio-recorder" data-control-family="audio-recorder" data-slot="root" data-state={recorder.state} data-disabled={recorder.isDisabled ? "true" : undefined} data-error={recorder.error ? "true" : undefined} role="group" aria-label={label} aria-disabled={recorder.isDisabled || undefined} className={cn("flex w-full min-w-0 max-w-full items-center", className)} {...props} > {children ?? <AudioRecorderDefaultLayout points={barCount} />} </div> </AudioRecorderContext.Provider> );}function AudioRecorderDefaultLayout({ points }: { points?: number }) { return ( <> <AudioRecorderTrigger /> <div data-control-ui="audio-recorder" data-control-family="audio-recorder" data-slot="content" className="grid min-w-0 flex-1 items-center overflow-hidden" > <AudioRecorderStatus className="col-start-1 row-start-1 w-full flex-none" /> <AudioRecorderVisualizer points={points} className="col-start-1 row-start-1 w-full" /> </div> <AudioRecorderDuration /> <div data-control-ui="audio-recorder" data-control-family="audio-recorder" data-slot="actions" className="flex shrink-0 items-center"> <AudioRecorderCancel /> <AudioRecorderSubmit /> </div> </> );}export type AudioRecorderTriggerProps = AudioRecorderStyleProps<ComponentProps<typeof Button>, ButtonKnobStyle & AudioRecorderKnobStyle>;export function AudioRecorderTrigger({ className, children, disabled, onClick, ...props }: AudioRecorderTriggerProps) { const recorder = useAudioRecorderContext(); const isActive = recorder.state === "requesting" || recorder.state === "recording" || recorder.state === "recorded" || recorder.state === "submitting"; const isStatusOnly = !recorder.canStart && (recorder.state === "requesting" || recorder.state === "recording" || recorder.state === "recorded" || recorder.state === "submitting"); const triggerVariant = audioRecorderTriggerVariant(recorder, isActive); const triggerTone = audioRecorderTriggerTone(recorder); const ariaLabel = audioRecorderTriggerLabel(recorder); async function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (!event.defaultPrevented && recorder.canStart) await recorder.start(); } return ( <Button data-control-ui="audio-recorder" data-slot="trigger" data-recorder-state={recorder.state} data-status-only={isStatusOnly ? "true" : undefined} type="button" variant={triggerVariant} tone={triggerTone} active={isActive} size="sm" iconOnly shape="circle" aria-label={ariaLabel} title={recorder.error?.message} disabled={disabled ?? (recorder.isDisabled || !recorder.canStart)} className={cn("data-[status-only=true]:cursor-default", className)} onClick={handleClick} {...props} > {children ?? <MicIcon aria-hidden="true" className="size-3.5" />} </Button> );}function audioRecorderTriggerVariant(recorder: UseAudioRecorderResult, isActive: boolean) { if (recorder.state === "recording") return "solid"; if (isActive) return "surface"; return "quiet";}function audioRecorderTriggerTone(recorder: UseAudioRecorderResult) { if (recorder.state === "recording" || recorder.state === "error") return "danger"; if (recorder.state === "requesting" || recorder.state === "submitting") return "primary"; return "neutral";}function audioRecorderTriggerLabel(recorder: UseAudioRecorderResult) { switch (recorder.state) { case "requesting": return "Requesting microphone permission"; case "recording": return "Recording voice"; case "recorded": return "Voice recording ready"; case "submitting": return "Sending voice recording"; default: return recorder.error ? "Retry voice recording" : "Start voice recording"; }}export type AudioRecorderVisualizerProps = AudioRecorderStyleProps< Omit<ComponentProps<"div">, "children">, AudioVisualizerKnobStyle & AudioRecorderKnobStyle> & { points?: number;};// owns only state gating and recorder anatomy hook, never waveform renderingexport function AudioRecorderVisualizer({ points, className, ...props }: AudioRecorderVisualizerProps) { const recorder = useAudioRecorderContext(); const isVisible = recorder.state === "recording" || recorder.state === "recorded"; return ( <AudioVisualizer data-control-ui="audio-recorder" data-slot="visualizer" data-visible={isVisible ? "true" : undefined} active={recorder.state === "recording"} levels={recorder.levels} points={points} aria-hidden={!isVisible} className={className} {...props} /> );}export type AudioRecorderStatusProps = AudioRecorderStyleProps<ComponentProps<"span">, AudioRecorderKnobStyle>;export function AudioRecorderStatus({ className, children, ...props }: AudioRecorderStatusProps) { const recorder = useAudioRecorderContext(); const message = children ?? audioRecorderStatusMessage(recorder); const isVisible = Boolean(message); return ( <span data-control-ui="audio-recorder" data-control-family="audio-recorder" data-slot="status" data-visible={isVisible ? "true" : undefined} data-tone={recorder.state === "error" ? "error" : "neutral"} aria-hidden={!isVisible} aria-live="polite" className={cn("min-w-0 flex-1 truncate", className)} {...props} > {message} </span> );}function audioRecorderStatusMessage(recorder: UseAudioRecorderResult) { if (recorder.state === "requesting") return "Allow microphone access"; if (recorder.state === "submitting") return "Sending voice recording"; if (recorder.state === "error") return recorder.error?.message; return null;}export type AudioRecorderDurationProps = AudioRecorderStyleProps<ComponentProps<"span">, AudioRecorderKnobStyle>;export function AudioRecorderDuration({ className, children, ...props }: AudioRecorderDurationProps) { const recorder = useAudioRecorderContext(); const isVisible = recorder.state === "recording" || recorder.state === "recorded"; return ( <span data-control-ui="audio-recorder" data-control-family="audio-recorder" data-slot="duration" data-visible={isVisible ? "true" : undefined} aria-hidden={!isVisible} className={cn("shrink-0", className)} {...props} > {children ?? formatAudioRecorderDuration(recorder.durationMs)} </span> );}export type AudioRecorderCancelProps = AudioRecorderStyleProps<ComponentProps<typeof Button>, ButtonKnobStyle & AudioRecorderKnobStyle>;export function AudioRecorderCancel({ className, children, disabled, onClick, tabIndex, ...props }: AudioRecorderCancelProps) { const recorder = useAudioRecorderContext(); const isVisible = recorder.canCancel; function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (!event.defaultPrevented && recorder.canCancel) recorder.cancel(); } return ( <Button data-control-ui="audio-recorder" data-slot="cancel" data-visible={isVisible ? "true" : undefined} type="button" variant="quiet" tone="danger" size="sm" iconOnly shape="circle" aria-label="Cancel voice recording" aria-hidden={!isVisible} tabIndex={isVisible ? tabIndex : -1} disabled={disabled ?? (recorder.isDisabled || !isVisible)} className={className} onClick={handleClick} {...props} > {children ?? <XIcon aria-hidden="true" className="size-3.5" />} </Button> );}export type AudioRecorderSubmitProps = AudioRecorderStyleProps<ComponentProps<typeof Button>, ButtonKnobStyle & AudioRecorderKnobStyle>;export function AudioRecorderSubmit({ className, children, disabled, onClick, tabIndex, ...props }: AudioRecorderSubmitProps) { const recorder = useAudioRecorderContext(); const isVisible = recorder.canSubmit; async function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (!event.defaultPrevented && recorder.canSubmit) await recorder.submit(); } return ( <Button data-control-ui="audio-recorder" data-slot="submit" data-visible={isVisible ? "true" : undefined} type="button" variant="solid" tone="primary" size="sm" iconOnly shape="circle" aria-label="Send voice recording" aria-hidden={!isVisible} tabIndex={isVisible ? tabIndex : -1} disabled={disabled ?? (recorder.state === "submitting" || !isVisible)} className={className} onClick={handleClick} {...props} > {children ?? <CheckIcon aria-hidden="true" className="size-3.5" />} </Button> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-audio-recorder-* · 4 knobsHow the cascade resolves--cui-audio-recorder-foreground<color>var(--muted-foreground)--cui-audio-recorder-active-foreground<color>var(--foreground)--cui-audio-recorder-recording-ring-color<color>oklch(from var(--destructive) l c h / 0.25)--cui-audio-recorder-error-foreground<color>var(--destructive-text)