Read Aloud Button
A per-message listen toggle — progress ring, tiny equalizer and a speed pill — driven by the browser's speech engine or by your own TTS callbacks.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/read-aloud-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ReadAloudButton": a circular play/pause
control for ONE message, wrapped in a progress ring, with a playback-speed pill
next to it. lucide-react for icons. No audio library — the browser's
SpeechSynthesis is the default engine, and any other engine plugs in through
callbacks.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
onPlay / onPause / onRateChange (they collide with the DOM media events).
The root is a wrapper: the two buttons inside it are the interactive leaves.
- text: string — the message to read. Split locally; never uploaded.
- engine?: "speech" | "external" (default "speech"). "speech" drives
window.speechSynthesis. "external" paints the identical state machine but
leaves playback to the callbacks — a server TTS stream, an <audio> element,
a native bridge.
- status?: ReadAloudStatus = "idle" | "loading" | "playing" | "paused" | "error"
— controlled; omit for uncontrolled. onStatusChange?: (s) => void fires on
every transition, whoever caused it (click, engine end, engine failure).
- progress?: number — controlled 0..1 ring. Omit in speech mode to use the
built-in estimate.
- rates?: number[] (default [1, 1.5, 2]); rate?: number (controlled);
defaultRate?: number; onRateChange?: (rate) => void. An empty or one-entry
`rates` hides the pill entirely.
- onPlay?: (rate: number) => void, onPause?: () => void — the user's intent,
fired in both engines.
- voice?: SpeechSynthesisVoice | null, speechLang?: string — passed to the
utterance; pair `voice` with a separate voice picker.
- size?: "sm" | "md" (default "md" = 36px circle), label?: string (accessible
name while idle, default "Read aloud"), disabled?: boolean.
- Accessible names for the other four states live in one module-level LABELS
map — this is copy-owned source, so localising means editing five strings in
one place, not threading a labels object through the props.
Behavior — the state machine
- idle --click--> loading --engine start--> playing --click--> paused --click-->
playing; end returns to idle; an engine failure goes to error, and a click in
error retries from the beginning.
- A click while loading CANCELS: the voice has not started yet, so "stop" is
what the user means. Fire onPause and go back to idle.
- aria-pressed is true while loading and playing, false while paused, idle or
error: "pressed" means a voice is currently speaking, and paused invites a
press to resume.
- Empty text or a browser without speechSynthesis makes the button
aria-disabled — NOT the disabled attribute — so it keeps focus, and clicking
it announces the reason instead of silently doing nothing. The `disabled`
prop is different: it is a real disabled attribute on both buttons.
Behavior — the speech engine
- Split the text into sentence-sized segments (cut after . ! ? ; and the CJK
equivalents; re-cut anything over ~180 chars at the last space or comma).
Two payoffs: Chromium silently truncates a single long utterance, and a
finished segment is an exact progress checkpoint that needs no engine
support. Speak segment n, and queue n+1 from its `end` handler.
- Every start increments a session counter; every handler bails when
`session !== current || utteranceRef.current !== thisUtterance`. Detach
onstart/onend/onerror/onboundary BEFORE any cancel(), because cancel() fires
`end` and a live handler would happily queue the next sentence.
- One voice per page: keep a module-level Set of mounted instances that own the
engine. Starting tells the others to release FIRST (detach + reset to idle),
then calls cancel(), then speaks — otherwise the interrupted button's `end`
handler resurrects its own chain on top of the new one.
- Progress: onboundary gives a charIndex (local voices emit it, most remote
ones do not). Use it when it arrives, otherwise estimate with
elapsed * ~14 chars/second * rate. Boundary events write refs only; ONE
interval (~120ms) turns them into state, so a long answer does not re-render
the row once per word. Keep the ring monotonic and cap it at 0.995 until the
engine really reports the end.
- Pause: call pause(), then verify ~160ms later. Mobile engines routinely
ignore pause() — if the engine is still speaking, cancel for real, store the
character offset and let Resume re-speak the remainder of the sentence.
- Cancelling while the engine is paused wedges the queue in Chromium (the next
speak() never starts): always resume() before cancel().
- Rate: an utterance's rate is frozen once queued. Changing speed mid-read
rebases the offset (measured or estimated), drops the utterance and speaks
the REMAINDER of the current segment at the new rate, so progress does not
jump. Changing speed while paused is deferred to the resume path.
- `canceled` / `interrupted` errors are our own cancels: ignore them. Anything
else is a real failure → error state + a polite announcement.
- A new `text` prop stops playback and resets — a stale voice reading the
previous message is worse than silence.
- Unmount cancels the utterance, but only if this instance owns the engine:
cancel() is global, and unmounting a button that was never speaking must not
silence the message that is. Clear every timer on the way out.
Behavior — external engine
- The click maps to onPlay(rate) / onPause(); the parent owns the truth and
feeds `status` and `progress` back. Nothing else in the component changes:
same ring, same glyphs, same ARIA.
- The optimistic transition is straight to "playing", never "loading": nothing
inside the component knows when someone else's fetch ends, and an
uncontrolled button stuck on a spinner forever is worse than a ring that
starts a beat early. A parent that wants the spinner sets status="loading"
itself from onPlay, and flips it to "playing" when audio actually starts.
Rendering & styling
- Semantic tokens only: bg-muted / text-muted-foreground idle, bg-primary/10 +
text-primary while active, bg-destructive/10 + text-destructive on error,
stroke-border for the ring track, stroke-current for the arc, ring for the
focus ring. No hex, no rgb(), no oklch(). cn() merges every className.
- The ring is one <svg viewBox="0 0 36 36"> absolutely inset in the button:
a track circle plus an arc drawn with strokeDasharray = 2πr and
strokeDashoffset = 2πr * (1 - progress), rotated -90deg. It is aria-hidden —
the state itself lives in the button's accessible name.
- Glyphs by state: speaker (idle), spinner (loading), three-bar equalizer
(playing), play (paused), warning triangle (error), muted speaker (no engine).
While playing, hover or keyboard focus cross-fades the equalizer into a pause
glyph, so the affordance appears exactly when it is needed.
- The equalizer keyframes ship inside the component through a React 19 hoisted
<style href precedence> (deduped across instances). Under
prefers-reduced-motion the bars are hidden and a static audio glyph takes
their place — "a voice is talking" survives, the motion does not; the ring's
transition and the spinner also stop.
- Announcements: one sr-only role="status" aria-live="polite" region carrying
"Reading aloud" / "Paused" / "Resumed" / "Finished reading aloud" / the speed
after a change. Clear it after ~4s so the SAME message can be announced twice
in a row — a live region stays silent on unchanged text.
- The wrapper only takes role="group" (with the label) when the speed pill is
present; a group of one is noise for a screen reader.
Customization levers
- Density: `size` swaps 36px for 32px; the ring is a viewBox so its stroke
scales with it. Change gap-1 for how tightly the pill hugs the button.
- Speed ladder: `rates` is the single source for both the pill's cycle and its
label — [1, 1.25, 1.5, 2] for a finer ladder, [] to drop the pill and ship a
plain listen button.
- Progress fidelity: raise the ~14 chars/second constant for faster voices, or
drop the estimate entirely (boundary events only) if you only ship local
voices; pass `progress` yourself to bypass both.
- Segment length: lower it for chattier progress and more resilience on flaky
engines, raise it toward the natural sentence for the most natural prosody.
- Glyph vocabulary: swap the equalizer for a waveform, or keep the pause glyph
permanently visible instead of cross-fading on hover.
- Colour: the whole control reads from text-primary / bg-primary/10 — point it
at a chart token or an accent variable and the ring follows, because the arc
is stroke-current.
- Announcement policy: set the live region to aria-live="off" when a
surrounding message toolbar already narrates the same transitions.
- Engine: `engine="external"` is the seam for ElevenLabs, OpenAI TTS, a native
bridge or a pre-rendered MP3 — keep the visuals, replace the sound.Concepts
- Session token — every start, restart and release bumps a counter that each utterance handler compares against; a superseded handler returns before it can advance a state machine that has already moved on. It is what makes cancel-and-respeak (speed change, resume-from-offset) safe.
- One voice per page —
speechSynthesis.cancel()is global and a cancelled utterance still firesend, so the interrupted button would queue its next sentence on top of the new one. Instances register in a shared set and the incoming reader tells them to let go before cancelling. - Measured vs estimated progress — word boundary events are the truth when the voice emits them, and a chars-per-second estimate covers the voices that do not; the ring only ever moves forward and never fills completely until the engine says the read is over.
- Rate rebase — playback rate is baked into an utterance when it is queued, so changing speed means folding everything spoken so far into a character offset and re-speaking the remainder; progress continues from where the voice actually was instead of restarting.
- Pause verification —
pause()is advisory on several mobile engines, so the component checks a beat later whether the voice actually stopped and, if not, cancels for real and remembers the offset — resume then re-speaks the rest of the sentence rather than nothing. - Silence on unmount — a voice that outlives the component that started it follows the user to the next page; unmount cancels, but only when this instance is the one holding the engine.
Scroll To Latest
A floating jump-to-latest pill for a growing chat viewport — a sentinel decides when the reader has left the floor, arrivals since then ride in the badge, and one click re-pins follow mode.
Translation Toggle
A per-message translation affordance — a pill that names the detected source language, crossfades between original and translation while the box tweens between their measured heights, re-aims at another target language from a menu, and stands in with a skeleton shaped by the source while a request is in flight.