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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-text-to-speech.jsonPrompt
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
charIndexrelative 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 onesliceand never arithmetic. AcharLengthof 0 is measured to the next whitespace instead, and any index below the current anchor is dropped, becausesentenceboundaries 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 mostchunkLengthand queueing the next one fromendis 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 page —
cancel()empties the whole global queue, and a cancelled utterance still firesend, 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 late —
getVoices()is empty on the first call in Chromium and filled in onvoiceschanged, 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 loopsuseSyncExternalStoreforever), and reportsloading/ready/emptyso 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, andresume()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
errorwithrefused: truerather 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 arecanceledandinterrupted, which are only your own stop arriving as an error event.
useFps
A frame-rate probe that reports rolling fps over a sliding window of requestAnimationFrame timestamps, counts jank frames, and pauses instead of reporting zero while the tab is hidden.
Pricing Table
A four-state pricing section with a data-derived monthly/yearly toggle and a highlighted recommended tier.