{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "live-announcer",
  "title": "Live Announcer",
  "description": "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`.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/live-announcer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * One shared place to say something to a screen reader when nothing on screen moved.\n *\n * Mount `<LiveAnnouncer />` once at the app root and call `announce(\"17 results\")` from\n * anywhere — an event handler, an async callback, a router subscription, code that is not\n * React at all. There is no provider and no context, so the call site does not have to be\n * a component or sit under one.\n *\n * Four things about live regions decide this component's shape, and each one is a way the\n * obvious implementation silently says nothing:\n *\n *  1. **A region has to be in the accessibility tree before the text arrives.** Rendering\n *     `<div aria-live=\"polite\">{message}</div>` only when there is a message inserts the\n *     region and its content in one mutation, and that is reliably ignored. So the regions\n *     here are always mounted and always empty until something is said, and a call that\n *     arrives before the announcer has mounted is held and written on a later task rather\n *     than rendered into the announcer's own first commit.\n *\n *  2. **The same string written twice in a row is not read twice.** The region's content\n *     did not change, so there is nothing to announce — which is why \"Copied\" works once\n *     and then goes quiet. Rather than appending invisible padding to make the text differ\n *     (the reader may read it, and the text is no longer the message), each politeness owns\n *     *two* regions and messages alternate between them: the receiving one always goes from\n *     empty to the message. Emptying the other one says nothing, because `aria-relevant`\n *     defaults to `additions text` and a removal is not in that set.\n *\n *  3. **`polite` and `assertive` need separate regions.** The politeness is read when the\n *     region is registered, so flipping one region's `aria-live` per message is unreliable.\n *     They are four static regions: two polite, two assertive.\n *\n *  4. **`role=\"status\"` is not the same as `aria-live=\"polite\"`.** The role also carries an\n *     implicit `aria-atomic=\"true\"` (present the region as a whole), while a bare\n *     `aria-live` leaves `aria-atomic` at `false` (present only what changed). Both are\n *     written out explicitly below so neither depends on a mapping being implemented.\n *\n * What it deliberately does not do: serialise a burst. Two messages of the same politeness\n * in one task collapse to the last one — the queue holds one message per politeness, not a\n * list — because a region holds one value and a reader polling after both writes would only\n * ever have seen the second. \"Loading…\" immediately followed by \"17 results\" should say the\n * latter, not both.\n *\n * Route changes are the usual first use, and the usual mistake is to do it twice: moving\n * focus to the new page's heading already announces the page. Announce the title *or* move\n * focus, not both.\n */\n\nexport type AnnouncerPoliteness = \"polite\" | \"assertive\"\n\n/** One politeness channel: two regions, written alternately so repeats still register. */\nexport interface AnnouncerChannel {\n  /** The two regions' text. At most one is non-empty. */\n  slots: readonly [string, string]\n  /** Which slot the next message goes into — always the one not currently holding it. */\n  next: 0 | 1\n}\n\nexport interface AnnouncerState {\n  polite: AnnouncerChannel\n  assertive: AnnouncerChannel\n}\n\n/** How long a message stays in its region before being emptied. */\nexport const DEFAULT_CLEAR_AFTER_MS = 7000\n\nexport const EMPTY_CHANNEL: AnnouncerChannel = { slots: [\"\", \"\"], next: 0 }\n\n/**\n * Puts `message` in the channel's empty slot and empties the other one.\n *\n * Pure, and the whole of point 2 above: the slot that receives the text goes from empty to\n * the message every time, so announcing the same string twice in a row is two changes\n * rather than none.\n */\nexport function writeChannel(\n  channel: AnnouncerChannel,\n  message: string\n): AnnouncerChannel {\n  const slots: [string, string] = [\"\", \"\"]\n  slots[channel.next] = message\n  return { slots, next: channel.next === 0 ? 1 : 0 }\n}\n\n/**\n * Empties both of a channel's regions without moving `next`.\n *\n * `next` is left where it is so the slot that just spoke stays the one skipped. That keeps\n * the invariant — the next write lands in a slot that did not just hold text — true after a\n * clear as well as after a write.\n */\nexport function clearChannel(channel: AnnouncerChannel): AnnouncerChannel {\n  return { slots: [\"\", \"\"], next: channel.next }\n}\n\nexport function channelHasText(channel: AnnouncerChannel): boolean {\n  return channel.slots.some((text) => text !== \"\")\n}\n\n// --- module store (so `announce()` works with no provider, context or React at all) ------\n\nconst EMPTY_STATE: AnnouncerState = {\n  polite: EMPTY_CHANNEL,\n  assertive: EMPTY_CHANNEL,\n}\n\nlet state: AnnouncerState = EMPTY_STATE\n/**\n * What is owed, at most one message per politeness.\n *\n * A map rather than a list, for two reasons that happen to be the same shape: a region\n * holds one value, so a burst collapses anyway (see the note at the top of the file), and\n * on a server — where this module lives as long as the process and nothing ever mounts to\n * drain it — a list would be a queue that only grows.\n */\nlet queued: { polite?: string; assertive?: string } = {}\nlet flushTimer: ReturnType<typeof setTimeout> | null = null\nconst listeners = new Set<() => void>()\n\nfunction emit(next: AnnouncerState) {\n  state = next\n  for (const listener of listeners) listener()\n}\n\n/**\n * Writes whatever is owed into the regions.\n *\n * Deferred by a task rather than run inline, which is what makes point 1 hold: a component\n * that announces from its own mount effect can run before the announcer's, and writing then\n * would put text into the announcer's first commit — the region and its content appearing\n * together, the one case that is reliably ignored. By the next task the empty regions have\n * been committed.\n */\nfunction flush() {\n  flushTimer = null\n  // Nothing is mounted to say it. What is owed is kept, not dropped: whichever announcer\n  // mounts next drains it (see `subscribe`).\n  if (listeners.size === 0) return\n\n  const batch = queued\n  queued = {}\n  let next = state\n  if (batch.polite !== undefined) {\n    next = { ...next, polite: writeChannel(next.polite, batch.polite) }\n  }\n  if (batch.assertive !== undefined) {\n    next = { ...next, assertive: writeChannel(next.assertive, batch.assertive) }\n  }\n  if (next !== state) emit(next)\n}\n\nfunction scheduleFlush() {\n  if (flushTimer === null) flushTimer = setTimeout(flush, 0)\n}\n\nfunction subscribe(listener: () => void) {\n  listeners.add(listener)\n  // A message said before any announcer existed is still owed. This is the other half of\n  // point 1 — held at `announce` time, written once there is a committed region for it.\n  if (queued.polite !== undefined || queued.assertive !== undefined) scheduleFlush()\n  return () => {\n    listeners.delete(listener)\n    // The last announcer leaving takes its regions with it, so a remount should start\n    // silent rather than re-presenting whatever was in them. Anything owed survives.\n    if (listeners.size === 0) state = EMPTY_STATE\n  }\n}\n\n/**\n * Says `message` to assistive technology without moving focus or changing the page.\n *\n * Safe to call from anywhere, including before `<LiveAnnouncer />` has mounted. Blank\n * messages are dropped — a region emptied and refilled with nothing is not a thing to say.\n *\n * `assertive` interrupts whatever is being read. Reserve it for something the reader has to\n * act on now (a session about to expire, a submission that failed); everything else is\n * `polite` and waits for a pause.\n */\nexport function announce(\n  message: string,\n  politeness: AnnouncerPoliteness = \"polite\"\n): void {\n  if (typeof message !== \"string\" || message.trim() === \"\") return\n  if (politeness === \"assertive\") queued.assertive = message\n  else queued.polite = message\n  scheduleFlush()\n}\n\n/**\n * Drops anything owed and empties every region.\n *\n * Emptying says nothing (see point 2), so this is how to cancel an announcement that is no\n * longer true — a route change landing before the previous page's \"loaded\" was read.\n */\nexport function clearAnnouncements(): void {\n  queued = {}\n  if (flushTimer !== null) {\n    clearTimeout(flushTimer)\n    flushTimer = null\n  }\n  emit(EMPTY_STATE)\n}\n\nfunction clearPoliteness(politeness: AnnouncerPoliteness) {\n  emit(\n    politeness === \"assertive\"\n      ? { ...state, assertive: clearChannel(state.assertive) }\n      : { ...state, polite: clearChannel(state.polite) }\n  )\n}\n\n/**\n * `announce`, for call sites that would rather import a hook.\n *\n * It returns the same module function every render, so it is stable as a dependency and as\n * a prop. It exists because `announce` is importable directly and some codebases still\n * prefer the hook shape — there is no context behind it.\n */\nexport function useAnnounce(): typeof announce {\n  return announce\n}\n\n// --- the component --------------------------------------------------------------------\n\nexport interface LiveAnnouncerProps {\n  /**\n   * Milliseconds before a message is removed from its region, per politeness.\n   *\n   * It is removed so a reader that walks the page later does not meet a stale sentence\n   * sitting in the tree; the removal itself is silent. Leave room for the message to have\n   * been read — a second or two is cutting it fine. `0` keeps messages indefinitely, which\n   * is fine if every announcement is soon replaced by the next one.\n   */\n  clearAfter?: number\n  className?: string\n}\n\n/**\n * The four live regions, visually hidden. Mount exactly one, at the app root.\n *\n * It must stay in the accessibility tree, so it is hidden with `sr-only` (clipped, still\n * announced) and never with `hidden`, `display: none`, `visibility: hidden` or\n * `aria-hidden` — any of those and nothing written here is ever read.\n */\nexport function LiveAnnouncer({\n  clearAfter = DEFAULT_CLEAR_AFTER_MS,\n  className,\n}: LiveAnnouncerProps) {\n  const [snapshot, setSnapshot] = React.useState<AnnouncerState>(() => state)\n\n  React.useEffect(() => {\n    const unsubscribe = subscribe(() => setSnapshot(state))\n    // A flush can land between this component reading `state` for its initial value and\n    // this effect running. Re-reading costs a bail-out when nothing changed and saves a\n    // dropped announcement when it did.\n    setSnapshot(state)\n    return unsubscribe\n  }, [])\n\n  // One timer per politeness rather than one for both: a polite message's timer firing must\n  // not take an assertive one that arrived a moment ago down with it.\n  useClearTimer(\"polite\", snapshot.polite, clearAfter)\n  useClearTimer(\"assertive\", snapshot.assertive, clearAfter)\n\n  return (\n    <div className={cn(\"sr-only\", className)}>\n      {([\"polite\", \"assertive\"] as const).map((politeness) =>\n        snapshot[politeness].slots.map((text, index) => (\n          <div\n            key={`${politeness}-${index}`}\n            // Role, politeness and atomicity all written out: `role` implies the other two,\n            // but which implications are honoured varies, and a region that is polite in\n            // one reader and absent in another is the failure this component exists to stop.\n            role={politeness === \"assertive\" ? \"alert\" : \"status\"}\n            aria-live={politeness}\n            aria-atomic=\"true\"\n          >\n            {text}\n          </div>\n        ))\n      )}\n    </div>\n  )\n}\n\nfunction useClearTimer(\n  politeness: AnnouncerPoliteness,\n  channel: AnnouncerChannel,\n  clearAfter: number\n) {\n  const hasText = channelHasText(channel)\n  React.useEffect(() => {\n    if (!clearAfter || !hasText) return\n    const timer = setTimeout(() => clearPoliteness(politeness), clearAfter)\n    return () => clearTimeout(timer)\n    // `channel` identity, not just `hasText`: every write is a new object, so a second\n    // message restarts the timer instead of being emptied on the first one's schedule.\n  }, [politeness, channel, hasText, clearAfter])\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"
}
