Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/* -------------------------------------------------------------------------- *
 * Platform surface
 *
 * `lib.dom` ships the Web Speech *event* types but not the `SpeechRecognition`
 * interface itself, and `webkitSpeechRecognition` is declared nowhere at all —
 * so the shapes this hook touches are declared locally and the constructor is
 * read through a cast. Nothing is added to the global scope on purpose:
 * augmenting `Window` from a file that ships into other codebases collides with
 * whatever the consumer's own TypeScript lib version already declares.
 * -------------------------------------------------------------------------- */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-speech-recognition.json

Prompt

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 by stop(), 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 with aborted it is classified benign, so a pause renders as "I didn't catch that" instead of a red error. blocked codes (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 finalTranscript and 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 from resultIndex duplicates 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 own start event, 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 network error code exists at all. This is a privacy fact about the platform, not an implementation detail: never present the feature as on-device.

On This Page