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 (no space at a Chinese / Japanese seam, see Behavior).
- `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. Every seam (result to result, session
to session, final to interim) is trimmed and gets one space, except when the
character on either side is kana, a CJK ideograph or CJK punctuation — those
scripts have no word spaces, so a space there would read "你好 今天".
`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 when `isListening` turns true, or on 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.