useSpeechRecognition
Speech-to-text on the Web Speech API — interim vs settled transcripts, a continuous mode that survives the engine's silence timeout without becoming unstoppable, and errors split into benign / recoverable / blocked.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-speech-recognition.jsonPrompt
Build a React + TypeScript "useSpeechRecognition" hook (React only — no npm
dependencies; browser Web Speech API `SpeechRecognition`).
Contract
- `useSpeechRecognition({ lang = "en-US", continuous = false, interimResults =
true, maxAlternatives = 1, onResult, onError } = {})`.
- Returns `{ isSupported, isListening, transcript, interimTranscript,
finalTranscript, error, start, stop, abort, reset }`. All four commands are
referentially stable, so they are safe in dependency arrays.
- `finalTranscript` accumulates every settled chunk across sessions until
`reset()`; `interimTranscript` holds the unsettled text of the current
utterance and is cleared whenever a session ends; `transcript` is the two
space-joined.
- `error` is `{ kind, severity, code, message }`. `kind` is the raw error code
(`no-speech` | `aborted` | `audio-capture` | `network` | `not-allowed` |
`service-not-allowed` | `language-not-supported` | `bad-grammar` |
`phrases-not-supported`) plus four synthesised ones: `unknown` (a code this
build does not know), `unsupported` (no constructor), `start-failed`
(`start()` threw), `restart-failed` (continuous mode gave up). `severity` is
`benign` | `recoverable` | `blocked` — see Behavior. `message` is the
engine's own text when it supplies one (Chrome usually leaves it empty) and
otherwise a built-in English explanation; UI copy must branch on `kind` /
`severity`, never on `message`.
- Export the classifier itself (`classifySpeechRecognitionError(code,
message?)`) and the list of spec codes, so a consumer can build a support
matrix or unit-test its own copy against the same table the hook uses.
- `onResult` receives `{ chunk, isFinal, confidence, alternatives,
finalTranscript, interimTranscript, transcript }` once per result event that
carried text; `alternatives` is only populated for a settled chunk with
`maxAlternatives > 1`.
Behavior
- The constructor is `window.SpeechRecognition ?? window.webkitSpeechRecognition`
— Chrome and Edge shipped only the prefixed name for a decade and Safari is
prefix-only, while Firefox implements neither, so `isSupported` is honestly
false there and every `start()` resolves to an `unsupported` error instead of
throwing. Detect through `useSyncExternalStore(noopSubscribe, detect, () =>
false)`, never during render, so SSR and the hydrating first paint agree.
- NEVER start on mount. `start()` must run inside a real user gesture: it is
what raises the microphone prompt, and a page that grabs the mic on load gets
blocked once and forever.
- `continuous: true` is not enough on its own. Engines end the session after a
few seconds of silence regardless of the flag, so the hook re-arms it from the
`end` event. The re-arm is authorised by exactly one thing: an intent flag set
by `start()` and cleared by `stop()`, `abort()`, unmount, and any non-benign
error. The pending restart timer (~120 ms, because restarting synchronously
inside `end` throws on some builds) is cleared by those same paths, and the
timer callback re-checks the intent before firing. Omitting either half is how
this API turns into a microphone that cannot be switched off — the single most
common bug built on it.
- Cap the storm: five consecutive sessions that die in under a second (no
microphone, blocked service) stop the machine with a `restart-failed` error
instead of hammering the engine forever. Any delivered result resets the
counter, so a healthy long-running dictation never trips it.
- Error severity is the product decision, not a detail. `no-speech` (silence
timeout) and `aborted` (your own `abort()`, a navigation, another session
taking the mic) are `benign`: the session merely ended and the UI should say
"I didn't catch that", never "recognition failed". `network` and
`audio-capture` are `recoverable` — pressing the button again may work.
`not-allowed`, `service-not-allowed`, `language-not-supported`,
`bad-grammar`, `phrases-not-supported`, `unsupported` and `restart-failed`
are `blocked`: retrying changes nothing until a human or the code changes
something, so those clear the intent flag inside the error handler and the
`end` that follows cannot re-arm anything. `error` is therefore not a
synonym for "broken" — consumers must read `severity`.
- `start()` is idempotent while listening (calling `recognition.start()` twice
is exactly what throws `InvalidStateError`), and a `start()` that lands while
a stopped session is still winding down is honoured on the following `end`
rather than thrown away — pressing Stop then Start quickly must not be a
no-op. Any throw from `start()` is caught and reported as `start-failed`.
- Results are rebuilt from the whole `SpeechRecognitionResultList` on every
event, not appended from `event.resultIndex`: engines re-deliver and revise
earlier results, and an append-only reading duplicates text when they do.
Settled results are concatenated into the session's text and joined onto the
carry-over from previous sessions, which is what makes an auto-restarted
transcript read as one continuous one. `reset()` during a live session records
the current result count and skips everything below it, so the cleared text
cannot be resurrected by the next event.
- `isListening` flips true on the engine's own `start` event and deliberately
stays true across an auto-restart gap — the intent never lapsed, and a
"listening" indicator that blinks off every few seconds looks broken. The
window between `start()` and the browser's permission answer therefore reads
as `false`; a consumer that wants a "requesting…" state tracks it locally
(set on press, clear on the first result or error).
- One recognition instance is created lazily on first `start()` and reused
(engines dislike a fresh object per utterance). Options are applied per
session, since engines ignore a `lang` / `continuous` change made mid-session,
so a change takes effect on the next session including an auto-restart.
`maxAlternatives` is clamped to at least 1: `0` lets the engine return zero
alternatives, i.e. an empty transcript.
- Callbacks and the values the installed handlers need live in latest-refs
written from an effect (never during render) and never enter a dependency
array, so inline arrows and inline options literals are safe.
- Unmount detaches every handler *first* and then calls `abort()`, so the
microphone is released without a late `error` / `end` writing into a dead
component, and the pending restart timer is cleared.
- Privacy is part of the contract: in Chrome and Edge the audio is streamed to
Google's speech service, and Safari sends it to Apple's unless the platform
can dictate locally. This is not on-device transcription — say so in the UI,
and keep it away from regulated content unless you have verified what your
target build does.
Rendering & styling
- The hook renders nothing. Consumers own the UI and should render settled text
in `text-foreground` and interim text in `text-muted-foreground italic`, so a
guess is never mistaken for a commitment. Put the transcript in a
`role="status" aria-live="polite"` container that is mounted from the first
paint (a live region inserted together with its text announces nothing) and
mark the interim span `aria-hidden`, or a screen reader reads every revision.
- Branch the error UI on `severity`: a muted note for `benign`, a normal panel
for `recoverable`, and `bg-destructive/10` + `text-destructive` plus recovery
steps for `blocked` (`not-allowed` earns "open the site settings, allow the
microphone, reload"). On the default monochrome palette, use *weight* rather
than hue to separate the lanes, and put body copy on tinted panels in
`text-foreground` — measured on the light theme, `text-muted-foreground` over
`bg-destructive/10` is 4.1:1, under AA.
- Semantic tokens only (`bg-card`, `bg-muted`, `text-muted-foreground`,
`border`, `bg-destructive/10`, `text-destructive`); any recording pulse gets
`motion-safe:animate-pulse` so reduced motion keeps the meaning without the
animation. Never render a success-looking transcript while the state is
unsupported or denied.
Customization levers
- `continuous` — one-shot voice search vs a long dictation that survives pauses.
The restart bridge only exists for the second, so a search box does not pay
for it.
- `interimResults` — off gives you settled text only (simpler UI, feels slower);
on gives the live "typing" effect that makes dictation feel responsive.
- `lang` — the BCP-47 tag decides the model; expose it as a picker if your users
switch languages, and remember it applies from the next session.
- `maxAlternatives` — raise it to 3-5 when you want a "did you mean" list from
`onResult`'s `alternatives`; the default 1 keeps payloads small.
- Restart tuning — the ~120 ms re-arm delay and the five-instant-deaths cap are
constants: raise the delay on engines that need more settling time, lower the
cap if you would rather fail fast in a kiosk.
- `onResult` — where a debounced draft save, a command parser, or a "send on
final" chat integration hangs off; `onError` is where analytics and toasts
belong, gated on `severity` so a pause never fires an alert.
- Want a hold-to-talk button instead of a toggle? Call `start()` on
`pointerdown` and `stop()` on `pointerup`, and keep a `pointermove` guard for
`e.buttons === 0` so a pointer released outside the button still stops it.Concepts
- Intent flag as the only restart authority — the auto-restart is gated on one boolean set by
start()and cleared bystop(),abort(), unmount and every non-benign error, and the pending 120 ms timer is cleared on those same paths and re-checks the flag when it fires. That pair is what separates "continuous listening" from "a microphone the user cannot switch off". - Silence timeout ≠ failure — engines end a session after a few seconds of quiet and report it as
no-speech; together withabortedit is classifiedbenign, so a pause renders as "I didn't catch that" instead of a red error.blockedcodes (not-allowed,service-not-allowed, …) park the machine instead, because restarting into a denied microphone just repeats the same rejection forever. - Interim vs settled — the result list carries both; unsettled text is a guess the engine may revise, so it is accumulated separately and rendered in muted italics, while settled chunks are appended to
finalTranscriptand never move. - Session carry-over + list rebuild — every event rebuilds the current session's text from the whole
SpeechRecognitionResultList(engines re-deliver and revise, and appending fromresultIndexduplicates text when they do), then joins it onto what earlier sessions settled — which is what makes an auto-restarted dictation read as one transcript. - Bridged
isListening— the flag stays true across the ~120 ms re-arm gap because the user's intent never lapsed; blinking it off every few seconds would make a working feature look broken. It turns true on the engine's ownstartevent, so the interval while the browser shows its permission prompt is honestly not "listening". - Cloud transcription — Chromium streams the captured audio to Google's speech service (Safari to Apple's unless it can dictate locally), which is why a
networkerror code exists at all. This is a privacy fact about the platform, not an implementation detail: never present the feature as on-device.
useWebShare
Opens the native share sheet and degrades honestly — a cancelled sheet is reported as dismissed rather than an error, files are checked with canShare first, and the fallback's own success or failure is reported as such.
useMutationObserver
A callback-ref hook that watches DOM changes with MutationObserver — filtered attributes, rAF-batched records, and a disconnect/write/observe guard against self-feeding loops.