Dictation button

Pro

A chat composer with voice input, where the mic springs into a recording pill with a smooth live waveform, a timer, and cancel and insert buttons. Words stream into the field as you speak, muted until the recognizer settles them.

Live · keyboard ready
Claude Sonnet 5.5
0:00
  • Chat and agent composers where people would rather talk than type: mobile, long prompts, accessibility.
  • Notes, comments and support replies where dictation saves time and the person still edits before sending.
  • Any field that already has a send action and needs a voice entry point with clear listening state.
  • Full duplex voice conversations with spoken replies; use a voice agent pattern with call controls instead.
  • Recording audio files to keep (voice memos); this inserts text and discards audio.
  • Command palettes where a single spoken command should run immediately; confirm hands-free from onSpeechEnd only if you are sure.

Installation

Pro source and install commands unlock with a Pro plan.

example.tsx
import { DictationButton, createWebSpeechRecognizer } from "@/components/arc/dictation-button/dictation-button"; const recognizer = createWebSpeechRecognizer({ lang: "en-US" }); export function Composer({ onSubmit }: { onSubmit: (text: string) => Promise<void> }) {  return <DictationButton    recognizer={recognizer}    placeholder="Message Claude"    onSend={onSubmit}    onStatusChange={(status, error) => { if (status === "denied") console.info("Microphone blocked", error); }}  />;}

Only the control, in your own composer

example.tsx
import { useState } from "react";import { DictationControl } from "@/components/arc/dictation-button/dictation-button"; export function Field() {  const [text, setText] = useState("");  const [live, setLive] = useState({ final: "", interim: "" });  return <div className="composer">    <p>{text} {live.final} <span className="muted">{live.interim}</span></p>    <DictationControl      onTranscript={setLive}      onCommit={final => { setText(t => (t ? t + " " : "") + final); setLive({ final: "", interim: "" }); }}      onCancel={() => setLive({ final: "", interim: "" })}    />  </div>;}

Your own speech service

example.tsx
import { DictationError, type DictationRecognizer } from "@/components/arc/dictation-button/dictation-button"; export const deepgram: DictationRecognizer = async (handlers, signal) => {  const stream = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => { throw new DictationError("not-allowed"); });  const socket = new WebSocket("wss://your-proxy.example.com/listen");  socket.onmessage = event => {    const { final, interim, level } = JSON.parse(event.data);    handlers.onTranscript({ final, interim });    handlers.onLevel(level);  };  signal.addEventListener("abort", () => { socket.close(); stream.getTracks().forEach(t => t.stop()); });  // ...pipe stream audio into the socket...  return { stop: () => new Promise(resolve => { socket.onclose = () => resolve(lastFinal); socket.send("finalize"); }) };};

API reference

4 parts. The first is the root.

DictationButton

A composer with voice input: the field, a model label, the dictation pill and send. Words stream into the field while the person speaks, interim guesses muted until they settle.

PropTypeDefaultDescription
valuestring–Field text, controlled.
defaultValuestring""Initial field text when uncontrolled.
onValueChange(value: string) => void–Called on typing, after a dictation is inserted, and when the field clears on send.
placeholderstring"Message Claude"Field placeholder.
recognizerDictationRecognizersimulatedSpeech source. Defaults to a simulated recognizer that never touches the microphone. Pass createWebSpeechRecognizer() or your own adapter.
onTranscript(transcript: { final: string; interim: string }) => void–Live, cumulative transcript of the current session.
onCommit(text: string) => void–Final text of a session, after it has been appended to the field.
onSend(text: string) => void | Promise<void>–Send the message. The send button shows a spinner while the promise is pending and the field clears when it resolves. Without it, sending is simulated.
onStatusChange(status: "idle" | "starting" | "listening" | "processing" | "denied" | "error", error?: DictationErrorCode) => void–Every state change, with the error code for denied and error.
autoPlaybooleantrue with the simulated recognizerLoop a scripted dictation (listen, insert, send) until the first pointer, key or focus inside the card. Pauses off screen.
maxDurationnumber120Seconds before listening stops and inserts on its own. 0 turns the limit off.
model{ name: string; mark?: ReactNode } | nullClaude Sonnet 5.5Model label at the start of the toolbar. null hides it.
disabledbooleanfalseDisables the field, mic and send.
aria-labelstring"Message"Accessible name of the field.
classNamestring–Class on the root.

DictationControl

Just the mic and recording pill, for a composer you already have. Same state machine, no field.

PropTypeDefaultDescription
recognizerDictationRecognizersimulatedSpeech source.
onTranscript(transcript: DictationTranscript) => void–Live text to render in your own field (style interim as muted).
onCommit(text: string) => void–Final text to append to your field.
onCancel() => void–The person cancelled; drop any live text you rendered.
onSpeechEnd() => void–The recognizer detected the end of an utterance. Call nothing, or confirm hands-free.
onStatusChange(status, error?) => void–Every state change.
maxDurationnumber120Seconds before listening inserts on its own.
labelstring"Dictate"Accessible name and tooltip of the mic.
disabledbooleanfalseDisables the mic.
classNamestring–Class on the wrapper.

useDictation

The headless state machine behind both parts: idle, starting, listening, processing, then idle, or denied and error. Returns status, transcript, error, startedAt, levelRef (0 to 1, read per frame), start, confirm, cancel and dismiss.

PropTypeDefaultDescription
optionsUseDictationOptions–recognizer, maxDuration, onTranscript, onCommit, onCancel, onSpeechEnd, onStatusChange.

DictationRecognizer

The adapter contract: (handlers, signal) => Promise<{ stop(): Promise<string> }>. Resolve once audio flows, reject with new DictationError("not-allowed") on a blocked microphone, call handlers.onTranscript({ final, interim }), handlers.onLevel(0..1), handlers.onSpeechEnd() and handlers.onError(code). Abort the signal to cancel.

PropTypeDefaultDescription
createSimulatedRecognizer(options?: { scripts?: DictationPhrase[][]; deny?: boolean; startDelay?: number; finalizeDelay?: number; wordInterval?: number }) => DictationRecognizer–Scripted speech with interim corrections and speech-like levels. deny: true rejects every start, like a blocked microphone.
createWebSpeechRecognizer(options?: { lang?: string; levels?: boolean }) => DictationRecognizer–Browser SpeechRecognition (Chrome, Edge, Safari) with levels from getUserMedia and an AnalyserNode. Rejects with "unsupported" where it is missing.
EnterorSpace
On the mic: start dictating. Focus moves to Insert.
Enter
While listening: stop and insert the transcript (also from anywhere inside the card).
Escape
While listening: cancel and discard. On Try again: dismiss the error.
Enter
In the field: send. Shift+Enter adds a line.
Tab
Moves through cancel, insert, then send. Hidden faces of the pill are inert.
  • A polite live region announces listening (and that the default recognizer is simulated, nothing is recorded), transcribing, the inserted word count, cancel, send and every error with how to fix it. Streaming words are not announced, to keep screen readers quiet.
  • Status is never color alone: denied shows a mic-off icon and "Microphone blocked in site settings"; errors show an alert icon and a short reason; Try again keeps the same icon.
  • Only the visible face of the pill is focusable (the other faces are inert), and focus follows the action: mic to Insert, Insert back to the field at the end of the text, cancel back to the mic, a failed start to Try again.
  • The elapsed time is a role="timer" with tabular numerals in a reserved width; the waveform is decorative and hidden from assistive tech.
  • Mic, cancel and Insert are icon buttons with accessible names and tooltips (Dictate, Cancel, Insert, Transcribing).
  • Interim words use a muted tone between --text-muted and --text-secondary and settle to --foreground, so the difference reads in both themes without relying on hue.
  • The mic springs leftward into the recording pill (up to 216px, measured from the toolbar) with a critically damped spring (0.24s visual duration, no bounce); Try again springs to its measured label width. The mic's spot becomes Insert, so the next action sits where the pointer already is.
  • Faces crossfade (out 100ms, in 160ms). The toolbar label swaps with a single 2px crossfade. Insert crossfades its check into a spinner while transcribing.
  • Words only fade in (140ms). A corrected interim guess refades just the words that changed. The field springs to each new line.
  • The waveform fits as many 3px rounded bars as the track holds with an even 2px gap, so no bar is ever clipped. A level sample enters on the right every 60ms and each bar eases toward it every frame, so heights glide without steps or jitter, written straight to the DOM with no React renders. When listening stops, the bars settle in place to a row of dots.
  • Reduced motion: no size springs, no word fades, the waveform stops travelling and pulses in place with the voice. Loops pause off screen and in background tabs.
  • Fluid to 640px. The pill keeps a fixed 216px so nothing reflows while recording; at 360px the toolbar label gives way while recording.
  • Container queries, not viewport: keyboard hints hide under 460px of card width and error copy shortens under 400px.
  • All targets are 36px in the toolbar; the pill's cancel and insert are 28px inside a 36px pill with 4px padding.
  • The field grows to six lines, then scrolls, and keeps the newest words in view while dictating.
  • Levels live in a ref and the waveform and timer write to the DOM directly, so a session renders React only when the transcript changes.
  • The waveform frame loop stops once the bars have settled and pauses while off screen or in a background tab.
  • The Web Speech adapter releases the microphone tracks and closes the AudioContext on stop, cancel, error and unmount.
  • No audio is buffered in memory; text arrives as strings from the recognizer.

Notes for AI

Give your coding assistant the Markdown reference instead of screenshots.

  • Use DictationButton when you need the whole composer; use DictationControl inside an existing composer and render onTranscript yourself with interim text muted.
  • The default recognizer is simulated. For production pass createWebSpeechRecognizer() (free, browser built-in, Chrome sends audio to Google) or write a DictationRecognizer for Deepgram, AssemblyAI, OpenAI or Whisper over a websocket; the contract is five callbacks and an AbortSignal.
  • Handle status "denied" in product copy: the component already says where to fix it, but you may want to link to your help article.
  • Do not pass autoPlay in production; it is for docs and marketing demos and defaults to off with a real recognizer.
  • Keep the model label honest: pass the model the composer actually sends to, or null.

The full library index for assistants is at /llms.txt.