ComponentsVoiceInput
VoiceInput
Capture microphone audio and show its input level.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- Motion for React
(motion)Animations, transitions, and reduced-motion handling.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import { VoiceInput } from "@dethink/components";
export function Example() {
return <VoiceInput onStream={(stream) => startTranscription(stream)} />;
}Try the examples, then open the code to use them in your app. The docs examples use local fake streams so they can be exercised without granting microphone access.
Basic
Activation requests a stream, calls onStream, and a second activation stops the owned tracks.
Ready for voice input
Show sourceexamples/voice-input/basic.tsx
"use client";
import { useState } from "react";
import { type VoiceInputState } from "@dethink/components";
import { VoiceInputDemoMedia } from "@/examples/_shared/voice-input-demo-media";
export function VoiceInputBasic() {
const [state, setState] = useState<VoiceInputState>("idle");
return (
<div className="flex w-full flex-col items-center gap-3">
<VoiceInputDemoMedia
onStateChange={setState}
onStream={() => setState("recording")}
onStop={() => setState("idle")}
/>
<p aria-live="polite" className="text-muted-foreground text-sm">
{state === "idle"
? "Ready for voice input"
: state === "recording"
? "Live stream handed to the app"
: state === "muted"
? "Stream is open, audio tracks are muted"
: state === "permission-denied"
? "Permission was denied"
: state === "unsupported"
? "Microphone APIs are unavailable"
: "Requesting microphone permission"}
</p>
</div>
);
}Variants
VoiceInput follows the same action hierarchy as Button and RevealButton.
Show sourceexamples/voice-input/variants.tsx
"use client";
import { type VoiceInputVariant } from "@dethink/components";
import { VoiceInputDemoMedia } from "@/examples/_shared/voice-input-demo-media";
const variants: VoiceInputVariant[] = [
"solid",
"soft",
"outline",
"ghost",
"destructive",
];
export function VoiceInputVariants() {
return (
<div className="gap-density-gap flex flex-wrap items-center justify-center">
{variants.map((variant) => (
<VoiceInputDemoMedia
key={variant}
labels={{ idle: `${variant} voice input` }}
variant={variant}
/>
))}
</div>
);
}Sizes
The control stays circular at every size. A small sunlight ring responds to audio inside the button.
Show sourceexamples/voice-input/sizes.tsx
"use client";
import { type VoiceInputSize } from "@dethink/components";
import { VoiceInputDemoMedia } from "@/examples/_shared/voice-input-demo-media";
const sizes: VoiceInputSize[] = ["xs", "sm", "md", "lg", "xl"];
export function VoiceInputSizes() {
return (
<div className="gap-density-gap flex flex-wrap items-center justify-center">
{sizes.map((size) => (
<VoiceInputDemoMedia
key={size}
labels={{ idle: `${size} voice input` }}
size={size}
variant="outline"
/>
))}
</div>
);
}States
Recording, muted, denied, unsupported, disabled, and reduced-motion states all keep native button semantics.
Recording
Muted
Permission denied
Unsupported
No microphone
Microphone in use
Unavailable settings
Cancelable permission request
Disabled
Reduced motion
Show sourceexamples/voice-input/states.tsx
"use client";
import { useState } from "react";
import { Button, VoiceInput } from "@dethink/components";
import { VoiceInputDemoMedia } from "@/examples/_shared/voice-input-demo-media";
export function VoiceInputStates() {
const [muted, setMuted] = useState(true);
return (
<div className="grid w-full gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Recording</p>
<VoiceInputDemoMedia labels={{ idle: "Start demo recording" }} />
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Muted</p>
<div className="flex items-center gap-2">
<VoiceInputDemoMedia
muted={muted}
labels={{ idle: "Start muted recording" }}
/>
<Button
size="sm"
variant="outline"
onClick={() => setMuted((value) => !value)}
>
{muted ? "Unmute" : "Mute"}
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">
Permission denied
</p>
<VoiceInputDemoMedia
mode="denied"
labels={{ idle: "Try denied microphone" }}
/>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Unsupported</p>
<VoiceInputDemoMedia
mode="unsupported"
labels={{ idle: "Try unsupported microphone" }}
/>
</div>
{(["missing", "busy", "constraints", "pending"] as const).map((mode) => (
<div key={mode} className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">
{mode === "missing"
? "No microphone"
: mode === "busy"
? "Microphone in use"
: mode === "constraints"
? "Unavailable settings"
: "Cancelable permission request"}
</p>
<VoiceInputDemoMedia
mode={mode}
labels={{ idle: `Try ${mode} microphone` }}
/>
</div>
))}
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Disabled</p>
<VoiceInput disabled labels={{ idle: "Voice input unavailable" }} />
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">
Reduced motion
</p>
<VoiceInputDemoMedia
motion="none"
labels={{ idle: "Start without motion" }}
/>
</div>
</div>
);
}Production-shaped compositions that wire VoiceInput into app-owned workflows. The voice memo recipe uses your real microphone so the waveform reacts to live sound; the others run on local fake streams.
Record, pause, and keep a memo
Use your real microphone to record up to one minute, pause and resume, play back, download, or discard a take. Audio stays on this device.
Show sourceexamples/voice-input/recipe-voice-memo.tsx
"use client";
import { useRef, useState } from "react";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
VoiceInput,
useVoiceRecorder,
type VoiceInputController,
type VoiceInputState,
} from "@dethink/components";
function formatDuration(ms: number) {
const seconds = Math.floor(ms / 1000);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
export function VoiceInputRecipeVoiceMemo() {
const controller = useRef<VoiceInputController>(null);
const [inputState, setInputState] = useState<VoiceInputState>("idle");
const [inputError, setInputError] = useState<string | null>(null);
const [playbackError, setPlaybackError] = useState(false);
const recorder = useVoiceRecorder({
maxDurationMs: 60_000,
onRelease: () => controller.current?.stop(),
});
const busy = recorder.state === "recording" || recorder.state === "paused";
const memo = recorder.recording;
const extension = memo?.mimeType.includes("mp4")
? "m4a"
: memo?.mimeType.includes("ogg")
? "ogg"
: "webm";
const status =
inputError ??
(recorder.error instanceof Error ? recorder.error.message : null) ??
(inputState === "permission-request"
? "Waiting for microphone permission. Activate again to cancel."
: recorder.state === "recording"
? "Recording your voice."
: recorder.state === "paused"
? "Paused. Your microphone remains open."
: recorder.state === "finalizing"
? "Finishing your recording…"
: recorder.state === "ready"
? "Your recording is ready."
: "Ready when you are. Activate the microphone to begin.");
return (
<Card className="mx-auto w-full max-w-md">
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle>Voice memo</CardTitle>
<CardDescription>
A thought worth keeping. Up to one minute.
</CardDescription>
</div>
<Badge
variant="soft"
tone={busy ? "destructive" : "neutral"}
size="sm"
>
{busy ? "Mic is on" : "On this device"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="border-border bg-muted/30 flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4">
<div className="flex items-center gap-3">
<VoiceInput
controllerRef={controller}
variant="solid"
size="lg"
disabled={recorder.state === "finalizing"}
onStateChange={(state) => {
setInputState(state);
if (state === "permission-request") setInputError(null);
}}
onStream={(stream) => {
setInputError(null);
setPlaybackError(false);
recorder.start(stream);
}}
onStop={(_stream, reason) => recorder.stop(reason)}
onError={(_error, state) => {
const messages: Partial<Record<VoiceInputState, string>> = {
"permission-denied":
"Allow microphone access in your browser settings, then try again.",
"device-not-found": "Connect a microphone, then try again.",
"device-busy":
"Close other apps using your microphone, then try again.",
"constraints-error":
"Your microphone cannot use these settings. Try another microphone.",
unsupported:
"Microphone access requires a supported browser and HTTPS.",
};
setInputError(
messages[state] ?? "Voice input failed. Please try again.",
);
}}
labels={{
idle: memo ? "Record another voice memo" : "Record voice memo",
recording: "Stop recording",
}}
/>
<div>
<p
className="text-foreground text-2xl font-medium tabular-nums"
role="timer"
aria-label="Recording duration"
aria-live="off"
>
{formatDuration(recorder.elapsedMs)}
</p>
<p className="text-muted-foreground text-xs">
of 1:00 · pauses excluded
</p>
</div>
</div>
{busy ? (
<Button
size="sm"
variant="outline"
onClick={
recorder.state === "paused" ? recorder.resume : recorder.pause
}
>
{recorder.state === "paused" ? "Resume" : "Pause"}
</Button>
) : null}
</div>
<p role="status" className="text-muted-foreground text-sm">
{status}
</p>
{memo ? (
<div className="space-y-2">
{/* User-recorded local audio has no generated transcript. */}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
key={memo.url}
className="w-full"
controls
preload="metadata"
src={memo.url}
aria-label="Voice memo playback"
onError={() => setPlaybackError(true)}
/>
{playbackError ? (
<p role="alert" className="text-destructive text-sm">
Playback is unavailable. You can still download this take.
</p>
) : null}
</div>
) : null}
</CardContent>
<CardFooter className="flex-wrap gap-3" justify="between">
<p className="text-muted-foreground text-xs">
Audio stays here until you download it.
</p>
<div className="flex flex-wrap gap-2">
{memo ? (
<a
className="text-primary focus-visible:ring-ring rounded px-2 py-1 text-sm font-medium underline underline-offset-4 focus-visible:ring-2"
href={memo.url}
download={`voice-memo.${extension}`}
>
Download
</a>
) : null}
{busy || memo || recorder.state === "error" ? (
<Button
size="sm"
variant="ghost"
onClick={() => {
recorder.discard();
setInputError(null);
setPlaybackError(false);
}}
>
Discard
</Button>
) : null}
</div>
</CardFooter>
</Card>
);
}AI composer
The app owns transcription or streaming work. VoiceInput only owns permission, stream lifecycle, and visual state.
Show sourceexamples/voice-input/recipe-ai-composer.tsx
"use client";
import { useState } from "react";
import {
Button,
Field,
FieldControl,
FieldLabel,
Textarea,
type VoiceInputState,
} from "@dethink/components";
import { VoiceInputDemoMedia } from "@/examples/_shared/voice-input-demo-media";
export function VoiceInputRecipeAiComposer() {
const [state, setState] = useState<VoiceInputState>("idle");
const [muted, setMuted] = useState(false);
return (
<form
className="border-border bg-background mx-auto max-w-2xl rounded-lg border p-4"
onSubmit={(event) => event.preventDefault()}
>
<Field id="voice-prompt">
<FieldLabel>Ask the assistant</FieldLabel>
<FieldControl asChild>
<Textarea
rows={4}
resize="none"
placeholder="Summarize the deployment blockers from this incident..."
/>
</FieldControl>
</Field>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p aria-live="polite" className="text-muted-foreground text-sm">
{state === "recording"
? "Voice stream is live"
: state === "muted"
? "Voice stream is muted"
: state === "permission-denied"
? "Microphone permission denied"
: "Type or start voice input"}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setMuted((value) => !value)}
>
{muted ? "Unmute" : "Mute"}
</Button>
<VoiceInputDemoMedia
muted={muted}
variant="solid"
onStateChange={setState}
onStop={() => setState("idle")}
/>
<Button size="sm">Send</Button>
</div>
</div>
</form>
);
}VoiceInput owns the stream it acquires. The native ref still points to the button; controllerRef exposes stop() and cancel(). Both release active tracks. Cancel also invalidates a pending permission request: a late stream is stopped without calling onStream. It cannot dismiss the browser permission dialog.
Activate again while permission is pending to cancel. Disabling or unmounting also releases the microphone. Mute disables audio tracks without releasing access. Use onStateChange for visible status and onError for recovery guidance. Errors distinguish denied permission, missing devices, busy devices, unsupported settings, unsupported APIs, and other failures. The waveform stays still when audio analysis is unavailable; it never invents speech.
Pass recorder.start to onStream and call recorder.stop(reason) from onStop. Set onRelease to controllerRef.current?.stop() to keep the microphone button synchronized when the recorder stops itself.
The hook returns state, elapsedMs, recording, error, start(stream), stop(reason?), pause(), resume(), and discard(). States are idle, recording, paused, finalizing, ready, and error. A recording contains blob, url, mimeType, durationMs, and reason. onRecording receives the completed take once. Copy the Blob if your app needs to retain it; the playback URL is revoked on the next take, discard, or unmount.
Options include maxDurationMs, mimeType, onRecording, onError, and onRelease. Format and duration settings apply to the next take. Omit mimeType for browser negotiation. Pause excludes time and audio from the take but keeps the microphone open. Duration limits use browser timers and are best effort in background tabs, not a sample-accurate media trim. Recording errors release the handed-in tracks.
Recording and playback format support varies by browser. Feature detection is performed on activation; unsupported recording does not leave the microphone running. No audio is uploaded. Your application owns transcription, persistence, file validation, and any consent required before sending recordings to a service.
VoiceInput is a native button: Tab to focus, Enter or Space to start, stop, or cancel. Use state labels to localize its accessible name. Keep a visible status alongside it; decorative waveform rays are hidden from assistive technology.
Reduced motion and motion="none" use static levels. Recording timers should use aria-live="off"; announce state changes in a separate polite status region. The component inherits semantic color, focus, and density tokens, supports RTL, and exposes --voice-input-size and --voice-input-icon-size for layout customization. Check keyboard navigation, browser permission denial, microphone unplugging, and local playback on your supported devices; automated media adapters cannot replace real-device checks.
SoundInput is now VoiceInput. Update imports and type names, the registry item to voice-input, the docs route to /components/voice-input, and any sound-input data-slot or CSS custom-property selectors to voice-input. No legacy alias is exported.
Exhaustive state handlers must include device-not-found, device-busy, constraints-error, and error. Stop reasons now also include cancel, disabled, and error. The recorder adds duration-limit. An onClick handler can preventDefault() to suppress activation. Only real acquisition failures are classified as device or permission errors; synchronous consumer callback failures release the stream and report error. Demo getUserMedia adapters must return owned streams that the component may stop.
VoiceInputProps extends native button props, except it owns children and accessible names through state labels.
| Prop | What it does | Default |
|---|---|---|
controllerRefRef<VoiceInputController> | Separate imperative ref with stop() and cancel(); the native ref still points to the button. | Not set |
getUserMediaVoiceInputMediaRequest | Optional instance-scoped acquisition adapter. Returned streams are owned and stopped by VoiceInput. | browser API |
variant"solid" | "soft" | "outline" | "ghost" | "destructive" | Visual treatment matching Button and RevealButton variants. | "soft" |
size"xs" | "sm" | "md" | "lg" | "xl" | Circular control size, unchanged while recording. Audio-responsive rays stay inside the button. | "md" |
motion"none" | "subtle" | "standard" | Controls the microphone and radial waveform transitions. None keeps the rays still. | "standard" |
mutedboolean | Externally controlled mute state. Active audio tracks are disabled while the stream stays open. | false |
audioboolean | MediaTrackConstraints | Audio constraints passed to getUserMedia when the user activates the control. | true |
labelsPartial<Record<VoiceInputState, string>> | State-specific accessible names, including cancellation while requesting and actionable device errors. | built-in copy |
onStream(stream: MediaStream) => void | Called with the live stream after microphone permission succeeds. | Not set |
onStop(stream: MediaStream, reason: VoiceInputStopReason) => void | Called after VoiceInput stops its owned tracks because of user stop, cancellation, disabling, errors, unmount, or track end. | Not set |
onError(error: unknown, state: VoiceInputState) => void | Receives acquisition or synchronous consumer callback failures with their classified state. | Not set |
onStateChange(state: VoiceInputState) => void | Receives the public state whenever VoiceInput changes state. | Not set |
…native button propsButtonHTMLAttributes | Renders a real button and supports disabled, type, form, className, and event props except aria-label/aria-labelledby. | Not set |