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.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/read-aloud.json"