AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AudioLines, LoaderCircle, Pause, Play, TriangleAlert, Volume2, VolumeX } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Equalizer keyframes
 *
 * Shipped with the component through a React 19 hoisted <style>: no Tailwind
 * config edit, and every instance on the page dedupes by href. The bars are the
 * only decorative motion here, and they are hidden outright under
 * prefers-reduced-motion — a static glyph stands in for them, so "the voice is
 * talking" is still visible without anything moving.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/read-aloud-button.json

Prompt

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 pagespeechSynthesis.cancel() is global and a cancelled utterance still fires end, 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 verificationpause() 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.

On This Page