A screen-reader announcement for something that happened without anything on screen moving: mount one `<LiveAnnouncer />` at the app root and call `announce("17 results")` from anywhere — an event handler, an async callback, a router subscription, a module that is not React at all. There is no provider and no context, so the call site does not have to be a component or sit under one. Reach for it wherever the page changed its mind and the reader has no way to know: a single-page-app route change (Next.js App Router, React Router, TanStack Router, Remix), the result count after a filter or a search-as-you-type ("17 results", "no matches"), a background save landing or failing, a row deleted or restored, a table re-sorted, an item moved in a drag-and-drop list, "20 more loaded" on an infinite scroll, a copy-to-clipboard confirmation, a cart or quantity update, a websocket or server-sent event arriving, a validation summary after a submit, an optimistic update being rolled back, a session about to expire, a file finishing upload, a step completing in a wizard, or anything wired to a mutation you already have — a TanStack Query mutation settling, a Next.js server action behind useActionState, react-hook-form's formState, tRPC, SWR, Convex, Supabase or Firebase. Common asks it answers: "react screen reader announcement", "announce to screen reader react", "aria-live react", "live region react component", "useAnnounce hook", "shadcn live region", "shadcn announcer", "visually hidden live region", "sr-only aria-live", "aria-live not working", "aria-live not announcing", "screen reader not reading aria-live", "aria-live region ignored", "aria-live only works once", "same message not announced twice", "aria-live duplicate text not read", "announce the same string again", "role status vs aria-live polite", "aria-atomic default value", "aria-relevant default", "polite vs assertive aria-live", "when to use aria-live assertive", "announce route change react", "screen reader route change announcement next.js", "react router announce page change", "spa page title announcement accessibility", "announce filter results count accessibility", "accessible toast announcement", "live region must be in the dom first", "aria-live inserted with content not announced", "NVDA not reading aria-live", "VoiceOver ignores aria-live", "スクリーンリーダー 読み上げ react", "aria-live 読み上げられない", "同じ文字列が二回読み上げられない", "ルート遷移 読み上げ", "polite assertive 違い", "読み上げ専用の領域 react". **Four things about live regions decide the shape of this component, and each one is a way the six-line version silently says nothing at all.** **A region has to be in the accessibility tree before its text arrives.** Rendering `<div aria-live="polite">{message}</div>` only while there is a message inserts the region and its content in one mutation, which is reliably not announced — the single most common reason a live region "does not work". So the regions here are always mounted and always empty until there is something to say, and a call that arrives before the announcer has mounted is held and written on a later task rather than rendered into the announcer's own first commit. **The same string written twice in a row is not read twice.** The region's content did not change, so there is nothing to announce — which is exactly why a "Copied" announcement works once and then goes quiet. The usual patch is to append an invisible character so the text differs, which trades one bug for two: the reader may read the padding, and the announcement is no longer the message. Instead each politeness owns *two* regions and messages alternate between them, so the receiving region always goes from empty to the message. Emptying the other one says nothing, because `aria-relevant` defaults to `additions text` and a removal is not in that set. **`polite` and `assertive` need separate regions.** The politeness is taken when the region is registered, not when its text changes, so one region per app whose `aria-live` is swapped per message is unreliable. These are four static regions: two polite, two assertive, and no message can change another's politeness. **`role="status"` is not the same region as `aria-live="polite"`.** The role carries an implicit `aria-atomic="true"` — present the region as a whole — while a bare `aria-live` leaves `aria-atomic` at its default of `false`, present only the part that changed. `role="alert"` likewise implies `assertive` and atomic. Each region here writes out its role, its politeness and its atomicity, so nothing depends on which implication a given reader implements. Two further decisions, both deliberate. A burst collapses: two messages of one politeness in a single task leave only the last, because a region holds one value and a reader polling after both writes would only ever have seen the second — "Loading…" immediately followed by "17 results" should say the latter, not both. And the queue holds one message per politeness rather than a list, which is the same decision seen from the server: a module that lives as long as the process and never mounts an announcer to drain it would otherwise accumulate a queue forever. Messages are emptied from their region after `clearAfter` (7 seconds by default, `0` to keep them) so a reader walking the page later does not meet a stale sentence; the removal is silent, as above. The timer is per politeness, not shared — a polite message expiring must not take an assertive one that arrived a moment later down with it. The regions are hidden with `sr-only`, which clips them while leaving them in the accessibility tree, and never with `hidden`, `display: none`, `visibility: hidden` or `aria-hidden`, any of which means nothing written here is ever read. For route changes, which are usually the first reason anyone reaches for this: announce the new title **or** move focus to the new page's heading, not both — moving focus already announces the page, and doing both reads it twice. `announce`, `clearAnnouncements` and `useAnnounce` are the API; `writeChannel`, `clearChannel`, `channelHasText`, `EMPTY_CHANNEL` and `DEFAULT_CLEAR_AFTER_MS` are exported as plain values so the alternation can be tested or reused without rendering anything. Official shadcn/ui has nothing of the kind, measured today rather than remembered: fetching every entry in its registry — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of it, `aria-live`, `ariaLive`, `announce`, `politeness`, `assertive`, `aria-atomic` and `LiveRegion` are every one of them a zero hit. `role="status"` returns a single hit, and it is worth naming rather than rounding to zero: it sits on the `Spinner` component's SVG beside a fixed `aria-label="Loading"` — a label on a decorative element, not a region whose text ever changes. Official has no way to say anything. Within pulld the hole is not that nobody announces; it is that everybody rebuilds it. 46 of the other 135 components carry their own `aria-live` and 23 their own `role="status"`, each for its own one sentence: `save-status` announces its own saving-and-saved wording, `toast` carries regions for its own toasts, `form-error-summary` moves focus instead. This is the one you call when what happened is not any component's to report. No dependencies beyond React and your `cn`.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/live-announcer.json"