{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "read-aloud",
  "title": "Read Aloud",
  "description": "A listen button that reads a passage aloud one sentence at a time, and reports only what the speech engine has actually confirmed. Reach for it wherever there is prose somebody would rather hear than read: a long article or blog post, documentation and how-to pages, a recipe or repair instructions being followed with both hands busy, release notes and changelogs, terms and privacy text, language-learning and reading-practice exercises, study notes and flashcards, an email or newsletter preview, a knowledge-base answer, an AI chat reply somebody wants to listen to, a news item read while cooking or driving, and anywhere listening is the accessible route for a reader with low vision, dyslexia, a migraine, or simply a long page and tired eyes. It is the output half of the Web Speech API and the mirror of pulld speech-input, which fills a field by voice; unlike pulld audio-recorder it records nothing, needs no microphone and raises no permission prompt. Common asks it answers: \"text to speech react\", \"read aloud button\", \"speech synthesis component\", \"react speechSynthesis hook\", \"tts button shadcn\", \"shadcn text to speech\", \"read article aloud react\", \"speak text button\", \"useSpeechSynthesis hook\", \"react-speech-kit alternative\", \"web speech api react component\", \"accessibility read aloud widget\", \"listen to this article button\", \"play pause stop speech react\", \"highlight sentence while reading aloud\", \"karaoke highlight text to speech\", \"split text into sentences for speech synthesis\", \"speechSynthesis.getVoices returns empty array\", \"getVoices empty on first call\", \"voiceschanged never fires\", \"speech synthesis picks the wrong language voice\", \"speechSynthesis default voice wrong language\", \"speechSynthesis.speaking stays true when paused\", \"speechSynthesis pause not working\", \"speechSynthesis cancel fires error interrupted\", \"speech synthesis error on stop\", \"speech keeps talking after route change\", \"speechSynthesis still reading after navigation\", \"stop speech on unmount react\", \"two read aloud buttons on one page\". shadcn/ui has nothing that speaks: measured against the live catalogue on 2026-10-26 (65 items indexed, the 62 fetchable ones concatenated to 223,287 bytes of source), speechSynthesis, SpeechSynthesisUtterance, voiceschanged, getVoices, utterance and onboundary are all zero hits, and no official component name mentions speech, voice or reading. So this is written from the platform up, and the platform lies in five specific ways that a four-line version believes. One: getVoices() answers with an empty array the first time it is called — measured 0 voices synchronously and 180 nine milliseconds later on voiceschanged — so a component that reads the list during render has nothing to choose from. Two: the voice flagged default is the system's voice, not a voice for the page; on a Mac whose system language was Japanese the default was Kyoko (ja-JP), which reads an English article in a Japanese accent, so the voice here is matched to the document's own lang (exact tag, then primary subtag, local voices first) and left to the engine when nothing matches. Three: speechSynthesis.speaking is true in three places where nothing of yours is being read — before start fires (measured 51ms), for the whole of a pause (paused and speaking are true together), and inside your own end handler while anything else is queued — so every state here is assembled from the start, end, pause, resume, boundary and error events instead. Four: cancel() delivers error with code \"interrupted\", never end, so a component that reports every error puts a failure on screen each time the user presses stop; interrupted is treated as a stop, and distinguished from the case where something else on the page took the voice away. Five: cancel(), pause() and resume() are page-wide and not yours, so the component tracks whether the shared voice belongs to it — two listen buttons on one page do not silence each other, and unmounting the one in a closed dialog leaves the article playing, while unmounting the one that is speaking stops it, which is the only thing that saves a single-page app from reading the previous route over the new page. The text is split into sentences so the index of the sentence being spoken is reportable (the chain costs a measured 2-3ms gap between pieces); the splitter keeps 3.5, Dr. Smith, e.g., example.com/a.b and the marker in \"1. Buy milk\" intact, treats the ideographic full stop as a boundary although no space follows it, breaks on paragraphs but never on a single newline, and falls back to a word boundary for a sentence longer than the limit. The widely repeated claim that Chrome cuts a long utterance off after about fifteen seconds was not reproduced: a single 1,484-character utterance spoke for 98.6 seconds and ended normally. The API is a button plus the hook behind it. ReadAloudButton takes text and optional lang, voice, rate, pitch, volume, maxChunkChars, iconOnly, showProgress, showStop, per-cause messages and onChunkChange / onDone / onFailure; useReadAloud returns status, isSpeaking, isPaused, chunks with their offsets in the original text, chunkIndex, a best-effort charIndex from boundary, the voice list, the chosen voice, a typed failure, and start / stop / pause / resume / toggle — which is what you build a sentence highlighter on. splitForSpeech, pickVoice, useSpeechVoices and isReadAloudSupported are exported for the parts worth overriding. Accessibility is the point of the component, so: the live region is mounted empty from the first render rather than inserted when something happens, it is kept deliberately coarse because a polite region rewritten once per sentence would queue an announcement for every sentence and read them over the passage itself, the sentence counter sits outside it and is aria-hidden, the button carries no aria-pressed because its action changes between three verbs rather than toggling one setting, and an unsupported browser or an unrecoverable failure gets aria-disabled rather than disabled so a keyboard or screen-reader user still reaches the control and hears why. Styled with shadcn tokens (input, accent, ring, muted-foreground) so it follows light and dark mode, className merges rather than fights, server-rendering safe, one file, no speech library.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/read-aloud.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2, Pause, Play, Square, TriangleAlert, Volume2, VolumeX } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Speech synthesis, read off `window` at call time rather than captured at module scope.\n *\n * Two reasons. On the server the global is not there at all, and a module-level `const synth =\n * window.speechSynthesis` makes importing this file throw during SSR rather than rendering a button\n * that says it cannot speak. And the object is a live singleton shared by the whole page, so the\n * answer to \"is there one\" has to be asked again each time rather than remembered.\n *\n * Unlike `SpeechRecognition` (see `speech-input`, which hand-declares its surface because the only\n * spelling browsers ship is the undeclared prefixed one) both of these are in `lib.dom.d.ts`, so\n * the platform types are used as they come.\n */\nfunction speechSynth(): SpeechSynthesis | null {\n  if (typeof window === \"undefined\") return null\n  const synth = window.speechSynthesis\n  return synth && typeof synth.speak === \"function\" ? synth : null\n}\n\nfunction utteranceConstructor(): typeof SpeechSynthesisUtterance | null {\n  if (typeof window === \"undefined\") return null\n  const ctor = window.SpeechSynthesisUtterance\n  return typeof ctor === \"function\" ? ctor : null\n}\n\n/** Whether this browser can speak at all. False on the server, so render the same tree either way. */\nexport function isReadAloudSupported(): boolean {\n  return speechSynth() !== null && utteranceConstructor() !== null\n}\n\n/**\n * The page's own language, which is the language its prose is written in.\n *\n * Same resolution as `speech-input` uses for dictation, for the same reason: left unset, the voice\n * is chosen by the user agent and the specification does not say how, so the one configuration\n * whose behaviour cannot be predicted is the one you get by not configuring it. Here it is worse\n * than unpredictable — see `pickVoice`, where the browser's own default voice was measured to be in\n * a different language than the page.\n */\nfunction documentLanguage(): string {\n  if (typeof document !== \"undefined\") {\n    const declared = document.documentElement?.lang\n    if (declared) return declared\n  }\n  if (typeof navigator !== \"undefined\" && navigator.language) return navigator.language\n  return \"en-US\"\n}\n\n/** The primary subtag, lower-cased: `pt-BR` and `pt` both reduce to `pt`. */\nfunction primarySubtag(lang: string): string {\n  return lang.toLowerCase().split(/[-_]/)[0] ?? \"\"\n}\n\n/**\n * Chooses the voice to read `lang` with, or null to leave the choice to the engine.\n *\n * Exported because it is the decision most worth overriding, and because the obvious implementation\n * is wrong in a way that only shows up on somebody else's machine. `voices.find((v) => v.default)`\n * is what everybody writes first — and `default` marks *the system's* voice, not a voice for the\n * content. Measured on a Mac whose system language is Japanese (Chromium 152, 2026-10-26): of 180\n * installed voices the one flagged `default: true` was `Kyoko (ja-JP)`, so an English article read\n * with the default voice is an English article read by a Japanese synthesiser. The page's language\n * is the only thing that says how the text should sound.\n *\n * Order: an exact tag match, then any voice sharing the primary subtag (`en-AU` will read `en-GB`\n * prose far better than a Japanese voice will), then nothing. Returning null is deliberate — a\n * voice that cannot pronounce the text is worse than letting the engine pick, and the utterance\n * carries `lang` regardless, which is the standard way to ask for a language without naming a voice.\n *\n * Local voices are preferred over remote ones within each tier: a network voice depends on the\n * engine's server being reachable and is where the `network` error code comes from.\n */\nexport function pickVoice(voices: SpeechSynthesisVoice[], lang: string): SpeechSynthesisVoice | null {\n  if (voices.length === 0) return null\n  const wanted = lang.toLowerCase()\n  const base = primarySubtag(lang)\n  const score = (voice: SpeechSynthesisVoice): number => {\n    const voiceLang = (voice.lang ?? \"\").toLowerCase()\n    if (voiceLang === wanted) return voice.localService ? 0 : 1\n    if (base && primarySubtag(voiceLang) === base) return voice.localService ? 2 : 3\n    return Number.POSITIVE_INFINITY\n  }\n  let best: SpeechSynthesisVoice | null = null\n  let bestScore = Number.POSITIVE_INFINITY\n  for (const voice of voices) {\n    const value = score(voice)\n    if (value < bestScore) {\n      best = voice\n      bestScore = value\n    }\n  }\n  return best\n}\n\n/**\n * Keeps the installed voices in state, because the first read of them is empty.\n *\n * Measured (Chromium 152 / macOS, 2026-10-26): the first synchronous `getVoices()` on a fresh\n * document returned **0 voices**, and `voiceschanged` fired 9ms later with **180**. A component that\n * reads the list once during its first render therefore has nothing to choose from — which is how\n * read-aloud buttons end up using the system default voice for everything, in the wrong language.\n *\n * Both halves are needed and neither is redundant. The event is the only notification that the list\n * has arrived; but the specification fires it when the list *changes*, so a browser that already\n * had its voices loaded when this page parsed may never fire it at all — and there the seed read is\n * the only one that answers. The list is read once more at the moment of speaking (see\n * `speakChunk`) because that is free, and it covers an engine that populates lazily on first use\n * without announcing it.\n */\nexport function useSpeechVoices(): SpeechSynthesisVoice[] {\n  const [voices, setVoices] = React.useState<SpeechSynthesisVoice[]>(() => speechSynth()?.getVoices() ?? [])\n\n  React.useEffect(() => {\n    const synth = speechSynth()\n    if (!synth) return\n    const read = () => setVoices(synth.getVoices())\n    read()\n    synth.addEventListener?.(\"voiceschanged\", read)\n    return () => synth.removeEventListener?.(\"voiceschanged\", read)\n  }, [])\n\n  return voices\n}\n\n/** One piece of the text, with where it came from so a consumer can highlight it. */\nexport interface ReadAloudChunk {\n  text: string\n  /** Index in the original text of the first character of `text`. */\n  start: number\n  /** Index one past the last character. */\n  end: number\n}\n\n/**\n * How much text goes into one utterance by default.\n *\n * Small enough that a chunk is a sentence or two of prose, which is what makes the progress index\n * useful, and far below any length an engine is likely to refuse (`text-too-long` is a real error\n * code in the specification). See `splitForSpeech` for why the text is split at all.\n */\nexport const DEFAULT_MAX_CHUNK_CHARS = 300\n\nconst SENTENCE_ENDS = new Set([\".\", \"．\", \"!\", \"?\", \"…\", \"。\", \"！\", \"？\"])\nconst CLOSERS = new Set([\")\", \"]\", \"}\", '\"', \"'\", \"»\", \"”\", \"’\", \"）\", \"」\", \"』\", \"〉\"])\nconst SPEAKABLE = /[\\p{L}\\p{N}]/u\n\n/**\n * Words that end in a full stop without ending a sentence. Lower-cased, without the stop.\n *\n * Not exhaustive and cannot be: this is a heuristic over prose, and the cost of each entry is\n * asymmetric. A missing one splits a sentence in two, which costs a 2ms pause in the middle\n * (measured below) and a sentence-final fall in the voice. A wrong one glues two sentences\n * together, which costs nothing at all. So the list stays short and uncontroversial rather than\n * trying to cover every abbreviation in the language.\n */\nconst ABBREVIATIONS = new Set([\n  \"mr\", \"mrs\", \"ms\", \"mx\", \"dr\", \"prof\", \"sr\", \"jr\", \"st\", \"rev\", \"hon\", \"gen\", \"col\", \"sgt\", \"capt\",\n  \"lt\", \"vs\", \"etc\", \"eg\", \"ie\", \"al\", \"cf\", \"viz\", \"approx\", \"est\", \"dept\", \"univ\", \"inc\", \"ltd\",\n  \"co\", \"corp\", \"plc\", \"fig\", \"figs\", \"no\", \"nos\", \"vol\", \"vols\", \"pp\", \"ed\", \"eds\", \"ca\",\n  \"jan\", \"feb\", \"mar\", \"apr\", \"jun\", \"jul\", \"aug\", \"sep\", \"sept\", \"oct\", \"nov\", \"dec\",\n  \"mon\", \"tue\", \"tues\", \"wed\", \"thu\", \"thur\", \"thurs\", \"fri\", \"sat\", \"sun\",\n])\n\nconst isSpace = (ch: string | undefined): boolean => ch !== undefined && /\\s/.test(ch)\n\n/**\n * Whether the terminator at `index` really ends a sentence.\n *\n * `pieceStart` is where the current chunk began, which is what makes a leading list marker\n * recognisable: the stop in \"1. Buy milk\" is a numbering convention, not a sentence, and breaking\n * there leaves a chunk that is nothing but \"1.\" for the voice to read out as a number.\n */\nfunction terminatesSentence(text: string, index: number, pieceStart: number): boolean {\n  const ch = text[index]\n  // The CJK stops are unambiguous — the scripts that use them do not follow them with a space, so\n  // the \"whitespace after\" test below would reject every real sentence boundary in Japanese.\n  if (ch === \"。\" || ch === \"！\" || ch === \"？\") return true\n\n  // The terminator may be part of a run: \"?!\", an ellipsis, a stop inside a closing quote. The\n  // boundary is judged on what follows the whole run.\n  let last = index\n  while (last + 1 < text.length && (SENTENCE_ENDS.has(text[last + 1]!) || CLOSERS.has(text[last + 1]!))) {\n    last++\n  }\n  const after = text[last + 1]\n  // Followed immediately by more text, this is inside a word rather than after one: `3.5`, `e.g.`,\n  // `example.com`, `README.md`. One rule covers all of them, which is why there is no URL case.\n  if (after !== undefined && !isSpace(after)) return false\n  if (ch !== \".\" && ch !== \"．\") return true\n\n  // A full stop, followed by a space. Three things look like that and are not sentence ends.\n  const token = text.slice(pieceStart, index).trimStart()\n  const word = token.split(/\\s/).pop() ?? \"\"\n  // A list marker at the head of the piece: \"1.\", \"a.\", \"iv.\".\n  if (word === token && /^[\\p{N}a-z]{1,3}$/iu.test(word)) return false\n  // A single initial: \"J. R. R. Tolkien\", \"U. S.\".\n  if (/^\\p{L}$/u.test(word)) return false\n  // An initialism carries its own stops, so the word before the last one still has one in it:\n  // \"e.g\", \"i.e\", \"U.S\", \"a.m\". Matching the shape rather than listing them covers the lot, and it\n  // is also why the lookup below strips the inner stops — the set spells these without them.\n  if (/^(?:\\p{L}\\.)+\\p{L}$/u.test(word)) return false\n  return !ABBREVIATIONS.has(word.replace(/\\./g, \"\").toLowerCase())\n}\n\n/** Trims a range to its speakable extent and appends it, or appends nothing if there is none. */\nfunction pushRange(source: string, from: number, to: number, out: ReadAloudChunk[]): void {\n  let start = from\n  let end = to\n  while (start < end && isSpace(source[start])) start++\n  while (end > start && isSpace(source[end - 1])) end--\n  if (start >= end) return\n  const text = source.slice(start, end)\n  // A piece with no letter or digit in it — a rule of dashes, a row of asterisks — is silent. It\n  // was measured to settle harmlessly (an empty utterance fired `start` then `end` 3ms later), but\n  // it would still take up a place in the progress count and a turn in the chain, so the index a\n  // consumer highlights would drift off the sentence being spoken.\n  if (!SPEAKABLE.test(text)) return\n  out.push({ text, start, end })\n}\n\n/**\n * Splits text into the pieces that get spoken one at a time, with their offsets.\n *\n * **Why split at all.** The reason usually given is that Chrome cuts a long utterance off after\n * about fifteen seconds. That was **not reproduced** here: on Chromium 152 / macOS (2026-10-26) a\n * single 1,484-character utterance spoke for **98.6 seconds** and ended normally, and the same text\n * at `rate: 5` ended at 29.8s having fired 182 `boundary` events. So the cutoff is not a thing this\n * component can claim to work around, and it is not the reason the splitting is here.\n *\n * What survives measurement is narrower and still worth it. The specification has a `text-too-long`\n * error code, so a limit exists somewhere even where it was not hit. And the index of the sentence\n * being spoken is only observable between utterances: `boundary` gives a character offset, but\n * turning that into \"which sentence\" requires exactly this split anyway, and `boundary` is the\n * event most often missing in engines that have everything else. Chunking makes the useful signal\n * come from `start` and `end`, which every engine fires. The cost is a 2–3ms gap between pieces\n * (measured by chaining four sentences: `end` at 1969ms, next `start` at 1972ms), so splitting on\n * sentence boundaries is close to free; splitting mid-sentence is not, because the voice falls at\n * the end of an utterance as if it had finished a thought. Hence sentence boundaries first and the\n * character limit only as a backstop.\n *\n * A single newline is deliberately **not** a boundary. Hard-wrapped prose would otherwise be read\n * one line at a time with a sentence-final fall at the end of every line. Two or more newlines —\n * a paragraph break — are, since headings and list items often end with no punctuation at all.\n * Whether a lone newline sounds better broken has not been judged by ear here, so the conservative\n * reading wins; a consumer who disagrees can pre-split and pass the pieces in themselves.\n */\nexport function splitForSpeech(\n  text: string,\n  { maxChars = DEFAULT_MAX_CHUNK_CHARS }: { maxChars?: number } = {}\n): ReadAloudChunk[] {\n  const out: ReadAloudChunk[] = []\n  if (typeof text !== \"string\" || text.length === 0) return out\n  const limit = Math.max(1, Math.floor(maxChars))\n\n  /** Appends `from`..`to`, breaking it up if it is over the limit. */\n  const flush = (from: number, to: number) => {\n    let start = from\n    while (to - start > limit) {\n      const cut = start + limit\n      // Back up to the last space inside the limit so a word is never split in half. A single\n      // unbroken token longer than the limit — a long URL read out character by character — has no\n      // space to back up to, and is cut where the limit falls.\n      let boundary = cut\n      while (boundary > start && !isSpace(text[boundary])) boundary--\n      if (boundary <= start) boundary = cut\n      pushRange(text, start, boundary, out)\n      start = boundary\n    }\n    pushRange(text, start, to, out)\n  }\n\n  let pieceStart = 0\n  for (let i = 0; i < text.length; i++) {\n    const ch = text[i]!\n    if (ch === \"\\n\") {\n      // A run of whitespace holding two or more newlines is a paragraph break.\n      let end = i\n      let newlines = 0\n      while (end < text.length && isSpace(text[end])) {\n        if (text[end] === \"\\n\") newlines++\n        end++\n      }\n      if (newlines >= 2) {\n        flush(pieceStart, end)\n        pieceStart = end\n      }\n      i = end - 1\n      continue\n    }\n    if (!SENTENCE_ENDS.has(ch) || !terminatesSentence(text, i, pieceStart)) continue\n    let end = i + 1\n    while (end < text.length && (SENTENCE_ENDS.has(text[end]!) || CLOSERS.has(text[end]!))) end++\n    flush(pieceStart, end)\n    pieceStart = end\n    i = end - 1\n  }\n  flush(pieceStart, text.length)\n  return out\n}\n\n/**\n * Why reading stopped or refused to start.\n *\n * `interrupted` is the one that is not an error. Measured: calling `speechSynthesis.cancel()` while\n * an utterance was speaking produced an `error` event with code `interrupted` 1ms later, never an\n * `end` — so a component that reports every `error` puts a failure on screen every single time the\n * user presses stop. It is kept as a cause rather than dropped because it also arrives when\n * *something else on the page* takes the voice away, and that is worth telling a consumer about.\n */\nexport type ReadAloudFailureCause =\n  | \"unsupported\"\n  | \"nothing-to-read\"\n  | \"interrupted\"\n  | \"voice-unavailable\"\n  | \"text-too-long\"\n  | \"audio-unavailable\"\n  | \"network\"\n  | \"not-allowed\"\n  | \"invalid-argument\"\n  | \"synthesis-failed\"\n  | \"unknown\"\n\nexport interface ReadAloudFailure {\n  cause: ReadAloudFailureCause\n  /** The engine's own error code, or null when this was settled before speaking began. */\n  code: string | null\n  /** Wording safe to show as-is. Override per cause with the `messages` prop. */\n  message: string\n  /** Whether pressing play again, right now, could produce a different result. */\n  retryable: boolean\n}\n\n/**\n * Where reading has got to.\n *\n * `starting` is split from `speaking` because the gap is real and the component should not claim to\n * be reading before it is: measured 51ms between `speak()` returning and `start` firing for a local\n * voice, and `speechSynthesis.speaking` was already `true` throughout that gap (see `isSpeaking`).\n */\nexport type ReadAloudStatus = \"unsupported\" | \"idle\" | \"starting\" | \"speaking\" | \"paused\"\n\nconst RETRYABLE: Record<ReadAloudFailureCause, boolean> = {\n  unsupported: false,\n  \"nothing-to-read\": false,\n  interrupted: true,\n  \"voice-unavailable\": false,\n  \"text-too-long\": false,\n  \"audio-unavailable\": true,\n  network: true,\n  \"not-allowed\": false,\n  \"invalid-argument\": false,\n  \"synthesis-failed\": true,\n  unknown: true,\n}\n\nconst DEFAULT_MESSAGES: Record<ReadAloudFailureCause, string> = {\n  unsupported: \"This browser can't read text aloud.\",\n  \"nothing-to-read\": \"There's nothing here to read.\",\n  interrupted: \"Reading was interrupted.\",\n  \"voice-unavailable\": \"No voice is available for this language.\",\n  \"text-too-long\": \"That passage was too long for the speech engine.\",\n  \"audio-unavailable\": \"The audio device is busy or unavailable.\",\n  network: \"The voice couldn't be reached — it may need a connection.\",\n  \"not-allowed\": \"This page isn't permitted to play speech.\",\n  \"invalid-argument\": \"The speech settings were out of range.\",\n  \"synthesis-failed\": \"The speech engine couldn't read that.\",\n  unknown: \"Reading stopped unexpectedly.\",\n}\n\n/** Maps a spec error code to a cause. */\nfunction causeForCode(code: string): ReadAloudFailureCause {\n  switch (code) {\n    case \"canceled\":\n    case \"interrupted\":\n      return \"interrupted\"\n    case \"audio-busy\":\n    case \"audio-hardware\":\n      return \"audio-unavailable\"\n    case \"network\":\n      return \"network\"\n    case \"synthesis-unavailable\":\n    case \"synthesis-failed\":\n      return \"synthesis-failed\"\n    case \"language-unavailable\":\n    case \"voice-unavailable\":\n      return \"voice-unavailable\"\n    case \"text-too-long\":\n      return \"text-too-long\"\n    case \"invalid-argument\":\n      return \"invalid-argument\"\n    case \"not-allowed\":\n      return \"not-allowed\"\n    default:\n      return \"unknown\"\n  }\n}\n\n/**\n * Which hook instance the shared voice currently belongs to.\n *\n * `speechSynthesis` is one object for the whole page, and `cancel()` empties *its* queue — not\n * yours. The same shape as `fullscreen-button` reading `document.fullscreenElement`: without an\n * owner, two read-aloud buttons on one page each believe the speech is theirs, so unmounting the\n * one in a closed dialog silences the article, and stopping either one shows both as stopped. The\n * component that is speaking writes itself here; everyone else leaves the singleton alone.\n */\nlet voiceOwner: object | null = null\n\nexport interface UseReadAloudOptions {\n  /** The text to read. Split into utterances by `splitForSpeech`. */\n  text: string\n  /** BCP 47 tag to read in. Defaults to the document's own `lang`. */\n  lang?: string\n  /** A specific voice, overriding the match `pickVoice` would make. */\n  voice?: SpeechSynthesisVoice | null\n  /** 0.1–10, where 1 is the voice's normal pace. */\n  rate?: number\n  /** 0–2, where 1 is the voice's normal pitch. */\n  pitch?: number\n  /** 0–1. */\n  volume?: number\n  /** Characters per utterance. See `DEFAULT_MAX_CHUNK_CHARS`. */\n  maxChunkChars?: number\n  /** Called as each piece starts, with its index and the piece itself. */\n  onChunkChange?: (index: number, chunk: ReadAloudChunk) => void\n  /** Called once the last piece has finished, and not when reading is stopped. */\n  onDone?: () => void\n  onFailure?: (failure: ReadAloudFailure) => void\n}\n\nexport interface UseReadAloudResult {\n  status: ReadAloudStatus\n  /**\n   * Whether a voice is producing sound right now.\n   *\n   * Not `speechSynthesis.speaking`, which was measured to be true in three situations where nothing\n   * of yours is being read: synchronously after `speak()` and throughout the 51ms before `start`\n   * fires; for the whole time an utterance is **paused** (`paused: true` and `speaking: true`\n   * together); and inside your own utterance's `end` handler when anything else is queued behind it\n   * — it describes the page's queue, not your utterance. Every state here is assembled from\n   * `start` / `end` / `pause` / `resume` / `error` instead.\n   */\n  isSpeaking: boolean\n  isPaused: boolean\n  isSupported: boolean\n  /** The pieces `text` was split into, in order. */\n  chunks: ReadAloudChunk[]\n  /** Index into `chunks` of the piece being read, or -1 when idle. */\n  chunkIndex: number\n  /**\n   * Best-effort offset into the original text of the word being spoken, or null.\n   *\n   * From `boundary`, which fired roughly once per word at every length measured here (7 events at\n   * 45 characters, 182 at 1,484). It is the first thing missing in an engine that supports\n   * everything else, so it is typed as nullable and nothing in this component depends on it —\n   * `chunkIndex` is the signal to build a highlight on, and this one refines it where it exists.\n   */\n  charIndex: number | null\n  voices: SpeechSynthesisVoice[]\n  /** The voice that will be used, or null to leave the choice to the engine. */\n  voice: SpeechSynthesisVoice | null\n  failure: ReadAloudFailure | null\n  /** Starts reading, from a given piece if one is named. Restarts if already reading. */\n  start: (fromChunk?: number) => void\n  stop: () => void\n  pause: () => void\n  resume: () => void\n  /** Play / pause / resume, as the button does. */\n  toggle: () => void\n}\n\n/**\n * Reads a passage aloud a sentence at a time, reporting only what the engine actually told it.\n */\nexport function useReadAloud({\n  text,\n  lang,\n  voice: voiceOverride,\n  rate = 1,\n  pitch = 1,\n  volume = 1,\n  maxChunkChars = DEFAULT_MAX_CHUNK_CHARS,\n  onChunkChange,\n  onDone,\n  onFailure,\n}: UseReadAloudOptions): UseReadAloudResult {\n  const isSupported = React.useMemo(() => isReadAloudSupported(), [])\n  const [status, setStatus] = React.useState<ReadAloudStatus>(isSupported ? \"idle\" : \"unsupported\")\n  const [chunkIndex, setChunkIndex] = React.useState(-1)\n  const [charIndex, setCharIndex] = React.useState<number | null>(null)\n  const [failure, setFailure] = React.useState<ReadAloudFailure | null>(null)\n\n  const voices = useSpeechVoices()\n  const chunks = React.useMemo(() => splitForSpeech(text, { maxChars: maxChunkChars }), [text, maxChunkChars])\n\n  const resolvedLang = lang ?? documentLanguage()\n  // Matched unconditionally, then overridden. `voiceOverride ?? useMemo(...)` reads better and is a\n  // conditional hook call: `??` short-circuits, so the memo is skipped on exactly those renders\n  // where a voice was passed, and the hook order changes the moment the prop appears or goes away.\n  const matchedVoice = React.useMemo(() => pickVoice(voices, resolvedLang), [voices, resolvedLang])\n  const voice = voiceOverride ?? matchedVoice\n\n  /**\n   * Which run of reading is current. Bumped before anything is cancelled, so the events belonging\n   * to the run being abandoned can be recognised and ignored.\n   *\n   * This is what keeps stop from being a pause. The chain advances in `end`, and `cancel()` was\n   * measured to deliver `error`, not `end` — but that only holds for the utterance that was\n   * speaking, and nothing in the specification promises it. A chain guarded only by \"did we get an\n   * error\" resumes at the next sentence the moment somebody presses stop. A generation counter does\n   * not care which event arrived.\n   */\n  const runRef = React.useRef(0)\n  /** This instance's identity, for `voiceOwner`. */\n  const ownerRef = React.useRef<object>({})\n  const chunksRef = React.useRef(chunks)\n\n  const onChunkChangeRef = React.useRef(onChunkChange)\n  const onDoneRef = React.useRef(onDone)\n  const onFailureRef = React.useRef(onFailure)\n  React.useEffect(() => {\n    onChunkChangeRef.current = onChunkChange\n    onDoneRef.current = onDone\n    onFailureRef.current = onFailure\n  }, [onChunkChange, onDone, onFailure])\n\n  // Read through refs so the chain, which outlives the render that started it, speaks at the\n  // settings that are current rather than the ones in force when play was pressed.\n  const settingsRef = React.useRef({ resolvedLang, voice, rate, pitch, volume })\n  React.useEffect(() => {\n    settingsRef.current = { resolvedLang, voice, rate, pitch, volume }\n    chunksRef.current = chunks\n  }, [chunks, pitch, rate, resolvedLang, voice, volume])\n\n  const report = React.useCallback((cause: ReadAloudFailureCause, code: string | null) => {\n    const next: ReadAloudFailure = {\n      cause,\n      code,\n      message: DEFAULT_MESSAGES[cause],\n      retryable: RETRYABLE[cause],\n    }\n    setFailure(next)\n    onFailureRef.current?.(next)\n  }, [])\n\n  /** Gives up the shared voice, if it was ours to give up. */\n  const releaseOwnership = React.useCallback(() => {\n    if (voiceOwner === ownerRef.current) voiceOwner = null\n  }, [])\n\n  const settle = React.useCallback(() => {\n    releaseOwnership()\n    setStatus(isReadAloudSupported() ? \"idle\" : \"unsupported\")\n    setChunkIndex(-1)\n    setCharIndex(null)\n  }, [releaseOwnership])\n\n  const speakFrom = React.useCallback(\n    (index = 0) => {\n      const synth = speechSynth()\n      const Utterance = utteranceConstructor()\n      if (!synth || !Utterance) {\n        report(\"unsupported\", null)\n        return\n      }\n      const pieces = chunksRef.current\n      if (pieces.length === 0) {\n        report(\"nothing-to-read\", null)\n        return\n      }\n      // `Math.max(0, NaN)` is NaN, so the clamp alone does not keep a bad index out of the array.\n      const asked = Number.isFinite(index) ? Math.trunc(index) : 0\n      const from = Math.min(Math.max(0, asked), pieces.length - 1)\n\n      // Order matters: the run is retired *before* the queue is emptied, so the `error` that\n      // `cancel()` produces for the utterance now being abandoned arrives stamped with a run that\n      // is no longer current and is ignored rather than shown as a failure.\n      const run = ++runRef.current\n      synth.cancel()\n      voiceOwner = ownerRef.current\n      setFailure(null)\n      setCharIndex(null)\n      setChunkIndex(from)\n      setStatus(\"starting\")\n\n      const speakChunk = (at: number) => {\n        if (run !== runRef.current) return\n        const list = chunksRef.current\n        if (at >= list.length) {\n          settle()\n          onDoneRef.current?.()\n          return\n        }\n        const piece = list[at]!\n        const { resolvedLang: utteranceLang, voice: chosen, rate: r, pitch: p, volume: v } = settingsRef.current\n        const utterance = new Utterance(piece.text)\n        utterance.lang = utteranceLang\n        // Read again rather than trusting the list from render: this costs nothing and is the only\n        // thing that works in an engine which populates its voices lazily on first use without\n        // firing `voiceschanged`.\n        const fresh = chosen ?? pickVoice(synth.getVoices(), utteranceLang)\n        if (fresh) utterance.voice = fresh\n        utterance.rate = r\n        utterance.pitch = p\n        utterance.volume = v\n\n        utterance.onstart = () => {\n          if (run !== runRef.current) return\n          setStatus(\"speaking\")\n          setChunkIndex(at)\n          onChunkChangeRef.current?.(at, piece)\n        }\n        utterance.onboundary = (event: SpeechSynthesisEvent) => {\n          if (run !== runRef.current) return\n          setCharIndex(piece.start + (event.charIndex ?? 0))\n        }\n        // The engine's own events, not an optimistic guess: `pause()` was measured to take 5ms to\n        // take effect, and a button that flips to \"Paused\" on the click and then never hears back\n        // is describing something that may not have happened.\n        utterance.onpause = () => {\n          if (run !== runRef.current) return\n          setStatus(\"paused\")\n        }\n        utterance.onresume = () => {\n          if (run !== runRef.current) return\n          setStatus(\"speaking\")\n        }\n        utterance.onend = () => {\n          if (run !== runRef.current) return\n          speakChunk(at + 1)\n        }\n        utterance.onerror = (event: SpeechSynthesisErrorEvent) => {\n          if (run !== runRef.current) return\n          const code = event.error ?? \"unknown\"\n          const cause = causeForCode(code)\n          // Still the current run and interrupted anyway: something else on the page took the\n          // voice. Our own stop retired the run before cancelling, so it never lands here.\n          settle()\n          report(cause, code)\n        }\n        synth.speak(utterance)\n      }\n\n      speakChunk(from)\n    },\n    [report, settle]\n  )\n\n  const stop = React.useCallback(() => {\n    runRef.current++\n    const synth = speechSynth()\n    // Only when the speech is ours. `cancel()` is page-wide, so stopping a button that is not\n    // speaking would silence whatever else is.\n    if (synth && voiceOwner === ownerRef.current) synth.cancel()\n    setFailure(null)\n    settle()\n  }, [settle])\n\n  const pause = React.useCallback(() => {\n    const synth = speechSynth()\n    if (synth && voiceOwner === ownerRef.current) synth.pause()\n  }, [])\n\n  const resume = React.useCallback(() => {\n    const synth = speechSynth()\n    if (synth && voiceOwner === ownerRef.current) synth.resume()\n  }, [])\n\n  const toggle = React.useCallback(() => {\n    if (status === \"speaking\") pause()\n    else if (status === \"paused\") resume()\n    // Pressing again before the voice has started is a change of mind, not a pause: `pause()` acts\n    // on a queue that has not begun, and nothing promises a `start` will arrive to be paused — so\n    // the honest answer to a second press in that 51ms window is to drop the reading entirely.\n    else if (status === \"starting\") stop()\n    else speakFrom(0)\n  }, [pause, resume, speakFrom, status, stop])\n\n  // Stopping on unmount is the whole reason this needs a cleanup: the voice is not attached to the\n  // component, so a route change in a single-page app leaves the previous article being read over\n  // the new page with no control anywhere that can stop it. A real document navigation does not\n  // need help — measured on Chromium 152 / macOS: after navigating away mid-utterance,\n  // `speechSynthesis.speaking` was already false 19ms into the new document — so there is no\n  // `pagehide` listener here, deliberately.\n  React.useEffect(() => {\n    return () => {\n      runRef.current++\n      const synth = speechSynth()\n      if (synth && voiceOwner === ownerRef.current) {\n        synth.cancel()\n        voiceOwner = null\n      }\n    }\n  }, [])\n\n  // Reading cannot continue into text that has been replaced under it.\n  const textRef = React.useRef(text)\n  React.useEffect(() => {\n    if (textRef.current === text) return\n    textRef.current = text\n    if (voiceOwner === ownerRef.current) stop()\n  }, [stop, text])\n\n  return {\n    status,\n    isSpeaking: status === \"speaking\",\n    isPaused: status === \"paused\",\n    isSupported,\n    chunks,\n    chunkIndex,\n    charIndex,\n    voices,\n    voice,\n    failure,\n    start: speakFrom,\n    stop,\n    pause,\n    resume,\n    toggle,\n  }\n}\n\nexport interface ReadAloudButtonProps\n  extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"children\" | \"onClick\">,\n    Omit<UseReadAloudOptions, \"text\"> {\n  /** The passage to read. */\n  text: string\n  /** Visible label, and the accessible name, while idle. */\n  label?: string\n  pauseLabel?: string\n  resumeLabel?: string\n  stopLabel?: string\n  startingLabel?: string\n  unsupportedLabel?: string\n  /** Announced once reading begins. Kept coarse on purpose — see the live region below. */\n  readingLabel?: string\n  pausedLabel?: string\n  /** Drop the visible text and keep it as the accessible name. */\n  iconOnly?: boolean\n  /** Hide the status line. It stays in the live region either way. */\n  hideMessage?: boolean\n  /** Show \"sentence 3 of 12\" beside the button. Never announced; see the live region below. */\n  showProgress?: boolean\n  /** Offer a separate stop button while reading. */\n  showStop?: boolean\n  /** Per-cause wording, merged over the defaults. */\n  messages?: Partial<Record<ReadAloudFailureCause, string>>\n  /** Class for the wrapper. `className` goes to the primary button. */\n  containerClassName?: string\n  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void\n}\n\n/**\n * A button that reads a passage aloud, and says only what the speech engine has confirmed.\n */\nexport function ReadAloudButton({\n  text,\n  label = \"Listen\",\n  pauseLabel = \"Pause\",\n  resumeLabel = \"Resume\",\n  stopLabel = \"Stop\",\n  startingLabel = \"Starting…\",\n  unsupportedLabel = \"Reading aloud isn't supported here\",\n  readingLabel = \"Reading aloud\",\n  pausedLabel = \"Paused\",\n  iconOnly = false,\n  hideMessage = false,\n  showProgress = false,\n  showStop = true,\n  messages,\n  containerClassName,\n  lang,\n  voice,\n  rate,\n  pitch,\n  volume,\n  maxChunkChars,\n  onChunkChange,\n  onDone,\n  onFailure,\n  onClick,\n  className,\n  ...props\n}: ReadAloudButtonProps) {\n  const { status, isSupported, chunks, chunkIndex, failure, toggle, stop } = useReadAloud({\n    text,\n    lang,\n    voice,\n    rate,\n    pitch,\n    volume,\n    maxChunkChars,\n    onChunkChange,\n    onDone,\n    onFailure,\n  })\n\n  const messageId = React.useId()\n  const active = status === \"speaking\" || status === \"paused\" || status === \"starting\"\n  const busy = status === \"starting\"\n  // A failure the user cannot do anything about leaves the button reachable and says why, rather\n  // than dangling an action that cannot work.\n  const actionable = isSupported && (active || failure?.retryable !== false)\n\n  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {\n    onClick?.(event)\n    if (event.defaultPrevented || !actionable) return\n    toggle()\n  }\n\n  const action = !isSupported\n    ? unsupportedLabel\n    : status === \"starting\"\n      ? startingLabel\n      : status === \"speaking\"\n        ? pauseLabel\n        : status === \"paused\"\n          ? resumeLabel\n          : label\n\n  // Deliberately coarse, and deliberately not the sentence number. A polite live region rewritten\n  // once per sentence queues an announcement for every sentence in the passage and reads them over\n  // the top of the passage itself — in a component whose entire job is to speak, that is the worst\n  // possible place to put a screen reader's attention. The sentence counter below is outside the\n  // region for the same reason.\n  const liveMessage = failure\n    ? failure.cause === \"interrupted\"\n      ? \"\"\n      : (messages?.[failure.cause] ?? failure.message)\n    : status === \"speaking\"\n      ? readingLabel\n      : status === \"paused\"\n        ? pausedLabel\n        : \"\"\n\n  const Icon = !isSupported\n    ? VolumeX\n    : busy\n      ? Loader2\n      : status === \"speaking\"\n        ? Pause\n        : status === \"paused\"\n          ? Play\n          : failure && !failure.retryable\n            ? TriangleAlert\n            : Volume2\n\n  return (\n    <div className={cn(\"flex flex-col items-start gap-2\", containerClassName)}>\n      <div className=\"flex items-center gap-2\">\n        <button\n          type=\"button\"\n          onClick={handleClick}\n          // No `aria-pressed`. This button's action changes between three verbs rather than\n          // toggling one setting on and off, and \"Listen, pressed\" is the wrong thing to hear while\n          // reading is paused. The name says what pressing it will do, which is what changes.\n          aria-label={iconOnly ? action : undefined}\n          // `aria-disabled` rather than `disabled`: a disabled button leaves the tab order, so a\n          // keyboard or screen-reader user never reaches it and never hears why reading is\n          // unavailable. `handleClick` is what refuses.\n          aria-disabled={actionable ? undefined : true}\n          aria-busy={busy || undefined}\n          aria-describedby={liveMessage ? messageId : undefined}\n          data-status={status}\n          data-cause={failure?.cause}\n          className={cn(\n            \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n            iconOnly ? \"w-9\" : \"px-4\",\n            className\n          )}\n          {...props}\n        >\n          <Icon className={cn(\"h-4 w-4\", busy && \"animate-spin\")} aria-hidden=\"true\" />\n          {iconOnly ? null : action}\n        </button>\n        {showStop && active ? (\n          <button\n            type=\"button\"\n            onClick={stop}\n            aria-label={stopLabel}\n            className=\"inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <Square className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n          </button>\n        ) : null}\n        {showProgress && active && chunkIndex >= 0 ? (\n          <span className=\"text-sm tabular-nums text-muted-foreground\" aria-hidden=\"true\">\n            {chunkIndex + 1} / {chunks.length}\n          </span>\n        ) : null}\n      </div>\n      {/*\n        Mounted from the start and left empty, never conditionally rendered: a live region inserted\n        into the document already holding its text is not reliably announced, so the version that\n        only appears when something happens is silent for exactly the people relying on it.\n      */}\n      <p\n        id={messageId}\n        role=\"status\"\n        aria-live=\"polite\"\n        className={cn(\"text-sm text-muted-foreground\", (hideMessage || !liveMessage) && \"sr-only\")}\n      >\n        {liveMessage}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
