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 (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 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