Hooks

useTextToSpeech

The browser speech engine as a hook — speak/pause/resume/cancel, a voice list that survives the async voiceschanged population, word boundary events mapped back into your own string, and a cancel on unmount so navigation never leaves a voice talking.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/* -------------------------------------------------------------------------- *
 * Types
 * -------------------------------------------------------------------------- */

/**
 * The playback state machine. **Five states**, and `pending` is the one the
 * platform gives you no name for: `speak()` was accepted but the engine has not
 * started talking yet — a remote voice is still being fetched, or the queue is
 * draining. A UI that folds that gap into `idle` looks broken for the half
 * second a cloud voice takes to warm up, and one that folds it into `speaking`

Installation

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

Prompt

Build a React + TypeScript "useTextToSpeech" hook (React only — no npm
dependencies; the browser's Web Speech API `speechSynthesis`).

Contract
- `useTextToSpeech({ text, voice = null, lang, rate = 1, pitch = 1, volume = 1,
  chunkLength = 180, onBoundary, onEnd, onError } = {})`.
- Returns `{ isSupported, voices, voicesState, status, isSpeaking, isPaused,
  boundary, error, speak, pause, resume, cancel }`. All four commands are
  referentially stable, so they are safe in dependency arrays and in memoised
  children.
- `status` is `idle | pending | speaking | paused | error`. `pending` is the
  state the platform has no name for: `speak()` was accepted but no `start`
  event has arrived (a remote voice is still being fetched). Folding it into
  `idle` makes the UI look broken while a cloud voice warms up; folding it into
  `speaking` shows a pause button that pauses nothing. `isSpeaking` is
  `pending || speaking`; `isPaused` is `paused`.
- `boundary` is `{ charIndex, charLength, word, elapsedTime, name } | null` and
  it is *what is being spoken right now* — it returns to null when nothing is.
  `charIndex` is an absolute index into the string that was passed to `speak()`,
  never into the internal utterance, so highlighting is
  `text.slice(charIndex, charIndex + charLength)`.
- `error` is `{ code, message, refused }`. `code` is any
  `SpeechSynthesisErrorCode` plus two the hook synthesises: `unsupported` (no
  engine) and `empty-text` (nothing but whitespace to say). `refused: true`
  marks those two — they never reached the engine, so render them as a plain
  note, not as a red failure. Branch copy on `code`, never on `message`.
- `voicesState` is `loading | ready | empty | unsupported`, because an empty
  `voices` array is ambiguous on its own: it means "ask again in a moment" in
  Chromium and "this machine has no voices" in a headless container, and a
  picker cannot render a sensible empty state without knowing which.
- Export the two pure helpers as well: `segmentSpeech(text, max)` returning
  `{ text, offset }[]`, and `describeSpeechFailure(code)` plus the code list, so
  a consumer can chart the segmentation or build a support matrix without first
  causing a failure.

Behavior
- Detect the engine through `useSyncExternalStore(noopSubscribe, () =>
  typeof window.speechSynthesis !== "undefined", () => false)` — never during
  render — so SSR and the hydrating first paint agree. `speak()` with no engine
  resolves to an `unsupported` refusal instead of throwing or doing nothing.
- The voice list is a **module-level store shared by every instance on the
  page**: one `voiceschanged` listener and one poll, not one per component.
  `getVoices()` returns `[]` on the first call in Chromium and fills in later;
  Firefox has it ready before the first call; a few builds populate late while
  firing no event at all. So back the event with a bounded poll (12 × 250 ms)
  that stops the moment either produces a voice, and land on `empty` when the
  budget runs out. The snapshot must be *cached* — `getVoices()` hands back a
  fresh array (and in some builds fresh voice objects) on every call, and an
  uncached `getSnapshot` makes `useSyncExternalStore` loop forever. Compare by
  count plus joined `voiceURI`s, not by identity.
- `speak(text?)` uses its argument, else the `text` option. It segments, claims
  the engine and starts, all synchronously in the handler: the session id, the
  segment list and the ownership flag are written in one shot because handlers
  can fire before the next render.
- **Segmentation is the reason long reads work.** Chromium stops speaking a
  single utterance after roughly fifteen seconds and reports nothing at all, and
  several engines drop the tail of a very long one. Cut the text at sentence
  punctuation (Latin and CJK) into pieces of at most `chunkLength`; a sentence
  longer than that is cut again at the last comma or space in the window, and a
  break landing in the first 40% of the window is rejected in favour of a hard
  cut so no runt segments are produced. Each piece carries its `offset` in the
  original string. `end` of segment n queues segment n+1 through a ref, so a
  handler installed three segments ago still reaches the current implementation.
- **Boundary maths**: absolute `charIndex = segment.offset + localOffset +
  event.charIndex`, where `localOffset` is non-zero only for a segment that was
  re-spoken from the middle. Where the engine reports `charLength: 0` (Safari),
  measure forward to the next whitespace instead. Drop any boundary whose
  absolute index is *below* the current anchor: engines emit `sentence`
  boundaries that repeat a low index, and accepting one drags the highlight
  backwards. The anchor moves to the next segment's offset at each `end`, so a
  restart before the first boundary of a segment resumes at that segment rather
  than at the previous word. Pass `elapsedTime` through untouched: the spec says
  seconds, Chromium has always sent milliseconds, so there is no unit worth
  normalising to — it is good for relative comparisons and nothing else.
- **`pause()` is not reliable, so verify it.** Android and several mobile Safari
  builds accept `pause()` and keep talking. Set the status optimistically, then
  after ~160 ms check `speaking && !paused`; if the engine ignored the request,
  detach, cancel for real, and remember that `resume()` must re-speak the rest
  of the current segment from the anchor rather than call `resume()` on nothing.
  Re-assert `paused` afterwards, because an utterance that started after the
  pause request has already flipped the status through its own `start` event.
- **Cancelling while paused wedges Chromium** — the next `speak()` is accepted
  and never starts. Every teardown therefore resumes first and cancels second.
- Rate, pitch and volume freeze when an utterance is queued. Changing one while
  a read is running keeps the anchor, drops the utterance and speaks the
  remainder at the new value (status only, so the highlight does not blank);
  while paused the change is deferred to `resume()`. Debounce a slider before it
  reaches the hook, or every pixel of the drag restarts the utterance.
- **One voice per page.** `speechSynthesis` is a single global queue and
  `cancel()` empties all of it, while a cancelled utterance still fires `end` —
  which for a hook that chains segments means the interrupted instance queues
  its next sentence on top of the new one. Keep a module-level registry of
  instances that hold the engine; a starting instance tells the others to
  release (detach handlers, drop to `idle`) *before* it cancels, so the cancel
  lands on nothing.
- `canceled` and `interrupted` error events are the hook's own stops arriving as
  errors; swallow them. Everything else becomes `error` and clears ownership.
- Changing the `text` option mid-read cancels: the indices in `boundary` address
  the string the read started with, and letting an old read report into a new
  one highlights arbitrary words in it.
- Every callback lives in a latest-ref written from an effect and never enters a
  dependency array — consumers pass inline arrows, and a callback in the deps
  would restart the voice on every render.
- **Cleanup**: unmount detaches every utterance handler, clears the pause
  verification timer, bumps the session so late events are ignored, and cancels
  the engine — but only if this instance owns it, or unmounting a silent
  component would silence a different one that is talking. The voice store drops
  its `voiceschanged` listener and its poll when the last subscriber leaves.

Rendering & styling
- The hook renders nothing; the consumer owns the UI. Recommended contract for
  it: one toggle button whose label changes (Read aloud → Pause → Resume) rather
  than a button that unmounts and swaps — swapping steals focus mid-read — plus
  a Stop that stays mounted and uses `aria-disabled` with a handler guard, never
  the native `disabled` attribute.
- Keyboard: the toggle is a real `<button>`, so Space and Enter come free; wire
  Escape to `cancel()` on the container, and give a speed control discrete
  buttons with `aria-pressed` rather than a slider that restarts the utterance
  on every pixel.
- ARIA: put the paragraph being read **outside** any live region — announcing a
  new word every 300 ms makes a screen reader unusable — and announce
  transitions instead through a small `role="status" aria-live="polite"` node
  that is mounted from the first paint and never carries the text itself. A
  progress bar gets `role="progressbar"` with `aria-valuenow`; the highlight
  itself is decoration.
- Semantic tokens only: spoken text `text-foreground`, text still ahead
  `text-muted-foreground`, the current word `bg-primary/15` with
  `text-foreground`, panels `bg-card` / `border`, refusals on `bg-muted/50` and
  real failures on `bg-destructive/10` with `text-destructive`. Body copy on a
  tinted panel is `text-foreground`: measured on the light theme,
  `text-muted-foreground` over `bg-destructive/10` is 4.1:1, under AA.
- Any progress transition is `transition-[width]` with
  `motion-reduce:transition-none`, and a "speaking" pulse is
  `motion-safe:animate-pulse` — reduced motion keeps the meaning, loses the
  movement.

Customization levers
- `chunkLength` — 180 is a safe default. Raise it for fewer seams on engines
  with a slow utterance start; lower it to make word highlighting recover faster
  on voices that emit no boundary events, since each segment boundary is itself
  an exact checkpoint. `0` speaks the whole text as one utterance: only for
  short strings, and never on Chromium for anything over a few seconds.
- `voice` + `lang` — pair the `voices` list with a picker, or leave both unset
  and let the OS default speak. A voice supplies its own `lang`; setting `lang`
  alone lets the engine choose. A voice change applies from the next segment.
- `rate` / `pitch` / `volume` — expose rate as 1x / 1.5x / 2x presets (the usual
  read-aloud affordance), keep pitch for character voices, and remember that
  each change re-speaks the current segment from the anchor.
- `onBoundary` — where a karaoke highlight, an auto-scroll or an analytics ping
  hangs off. If per-word re-renders are too costly for a very long document,
  ignore the `boundary` state entirely and write to a DOM node or a ref from
  this callback instead.
- `onEnd` — the "finished" flag a consumer needs to keep a progress bar at 100%,
  since `boundary` deliberately returns to null when nothing is being spoken.
- The pause verification window (~160 ms) and the voice poll budget
  (12 × 250 ms) are constants: shorten the first on a fast desktop-only build,
  lengthen the second on platforms whose voice list is known to arrive late.
- Want push-to-listen instead of a toggle? Call `speak()` on `pointerdown` and
  `cancel()` on `pointerup`, and keep a `pointermove` guard for `e.buttons === 0`
  so a pointer released outside the button still stops the voice.

Concepts

  • Boundary in your coordinates — the engine reports charIndex relative to the utterance it is speaking, which after chunking is a substring nobody outside the hook has seen. Adding the segment's offset (and the local offset of a re-spoken segment) turns it back into an index into the string you passed, so read-along highlighting is one slice and never arithmetic. A charLength of 0 is measured to the next whitespace instead, and any index below the current anchor is dropped, because sentence boundaries repeat a low index and would drag the highlight backwards.
  • Segment chaining beats the fifteen-second cliff — Chromium stops speaking a single long utterance after roughly fifteen seconds and reports no error, no end, nothing. Cutting at sentence punctuation into pieces of at most chunkLength and queueing the next one from end is what makes a full article read to the last word; as a bonus every segment boundary is an exact progress checkpoint on voices that emit no word boundaries at all.
  • One voice per pagecancel() empties the whole global queue, and a cancelled utterance still fires end, so an interrupted reader that chains segments would queue its next sentence over the new one. Instances that hold the engine sit in a registry, and whoever starts next makes the others let go before it cancels, so the cancel lands on nothing.
  • A voice list that arrives lategetVoices() is empty on the first call in Chromium and filled in on voiceschanged, ready immediately in Firefox, and occasionally populated late with no event at all. One page-wide store backs the event with a bounded poll, caches its snapshot (an uncached one loops useSyncExternalStore forever), and reports loading / ready / empty so a picker can tell "ask again" apart from "none installed".
  • Pause is a request, not a guarantee — mobile engines routinely accept pause() and keep talking. A verification timer catches that, stops the engine for real, and resume() re-speaks the rest of the current segment from the last word boundary. The mirror-image quirk is that cancelling while paused wedges Chromium, which is why every teardown resumes first and cancels second.
  • Refusal as a state — no engine and nothing-but-whitespace both resolve to a typed error with refused: true rather than a silent no-op, because a button that appears to do nothing is the hardest bug a user can report. They are rendered as a plain note, not a red failure — as are canceled and interrupted, which are only your own stop arriving as an error event.

On This Page