Feedback

Announcer

A screen-reader announcement centre — two always-mounted live regions, a queue that spaces messages out, an invisible marker that forces repeats to be read, and a timed clear.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Screen readers only announce a live region when its text *changes*, so setting the same
 * string twice in a row says nothing. When a message would land on the text already rendered,
 * an invisible zero-width space is appended: the text node becomes genuinely different while
 * the spoken sentence stays identical — U+200B carries no phonetic value and is not read out.
 * The next message drops it again (bare text no longer matches what is on screen), so the
 * marker only ever exists on an actual repeat. The alternative hacks — a trailing period, an
 * incrementing counter — are all audible.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/announcer.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Announcer" — an aria-live announcement
centre with no dependencies beyond React and a cn() class helper. One mount, and
an announce() function callable from anywhere (the Toaster/toast shape).

Contract
- <Announcer clearAfter={7000} gap={150} className? ...divProps /> renders the
  regions and nothing else. forwardRef to the wrapper div, spread the rest.
- announce(message, { politeness = "polite", dedupe = false, clearAfter? })
  queues a message. It is a plain module function, not a hook and not a context
  consumer: a data layer, a keyboard handler or a utility must be able to call
  it without being handed a provider.
- clearAnnouncements(politeness?) empties one region (or both) and drops what it
  had queued.
- subscribeAnnouncer(listener) / getAnnouncerSnapshot() / useAnnouncerState()
  expose the text each region currently renders — for a debug panel or a demo
  mirror, never for production UI.
- Numbers are normalised, not trusted: NaN and negative delays fall back to the
  default (a typo must not silently disable clearing), Infinity means "keep it
  until the next message", a non-finite gap would stall the queue forever.

Behavior — every rule below exists because of a way live regions fail
- The regions must already be in the document, empty, before their text
  changes. Inserting a live region that already carries text announces nothing
  in most screen readers. So: render server-side (no portal, no "mounted?"
  gate), and make the very first render of both regions empty even when
  messages were queued before mount — hold the text in component state that
  starts empty and only fill it from a store subscription in an effect. Any
  backlog is flushed one task after mount, into regions that already exist.
- polite and assertive are two separate elements. Never one element whose
  aria-live attribute is toggled: the attribute change races the text change it
  is supposed to describe, and the message is read with the wrong politeness or
  not at all.
- Repeats: setting a region to the string it already holds changes nothing in
  the DOM, so a screen reader stays silent — "Copied to clipboard" twice in a
  row would be announced once. Fix it by appending a zero-width space (U+200B)
  when the incoming message equals the text already rendered; write everything
  else bare, so the marker never trails a message that was not a repeat (and the
  message after a repeat differs again by losing it). The text node really
  changes; U+200B has no phonetic value, so the sentence a user hears is
  unchanged. Do not use a trailing period or a visible counter — both are read
  out. Keep the comment explaining this next to the constant; it looks like
  dead weight and gets "cleaned up" otherwise.
- Queue: two messages written in the same frame only announce the last one, so
  each region drains one message per `gap` (default 150ms) through its own
  timer. The two regions have independent queues — an assertive interruption
  never waits behind a polite backlog. Cap each queue (8 is plenty) and drop the
  oldest on overflow: a screen reader user needs the app's newest state, not a
  message that stopped being true seconds ago.
- Clear: `clearAfter` ms after a message is written, empty the region again.
  Otherwise a virtual-cursor user browsing the page later walks into a pile of
  stale status text. Re-arm this timer on every new message; 0/Infinity keeps
  the text until the next message replaces it.
- Cleanup: on unmount, clear every gap and clear timer, empty both regions and
  drop both queues. Warn in development when more than one <Announcer /> is
  mounted — each extra mount means each message is announced again.
- announce() on the server is a no-op, and empty/whitespace-only messages are
  ignored (they would only blank the region).
- No animation anywhere, so there is nothing for prefers-reduced-motion to turn
  off.

Rendering & styling
- The wrapper is sr-only: absolutely positioned, 1x1, padding 0, margin -1px,
  overflow hidden, clip-path inset(50%), white-space nowrap. It must NOT be
  display:none, visibility:hidden, hidden or width/height 0 with no content —
  those remove the element from the accessibility tree and silence the very
  announcement it exists to make.
- Regions: <div aria-live="polite" role="status" aria-atomic="true"> and
  <div aria-live="assertive" role="alert" aria-atomic="true">, each carrying a
  data attribute so tests can find them. aria-atomic makes the whole message be
  read rather than the diff.
- Semantic tokens only, cn() merges the consumer's className. There is no
  visible surface to theme — everything visible in the docs demo is the demo's
  own mirror panel.

Customization levers
- Delays: `clearAfter` (how long a message lingers) and `gap` (queue spacing)
  are the two knobs worth touching. Long forms may want 10-12s; a live filter
  count is better at 3-4s. Per-message overrides go in the options object.
- Queue policy: MAX_QUEUED and "drop the oldest" can become "drop the newest"
  or "collapse to the last message" for a firehose source such as a progress
  stream.
- Politeness routing: instead of asking every call site, wrap announce() in your
  own helper that maps app events to lanes (errors and destructive results ->
  assertive, everything else -> polite).
- Dedupe: flip the default to true if your call sites fire on every keystroke;
  keep it false when a repeated action must be confirmed out loud each time.
- Extra lanes: the region record is keyed by politeness — adding a third region
  (for example a log-style aria-live="polite" aria-relevant="additions" list
  that appends instead of replacing) means adding a key, not restructuring.
- Rendering: give the wrapper an id/className if a test harness or an existing
  design system needs to find it; keep it sr-only either way.

Concepts

  • Mounted empty, mutated later — the region has to be in the document before it has anything to say. A live region inserted with its text already in place is not announced by most screen readers, which is why this component is server-rendered, never portalled, and starts from an empty first render even when messages are already queued.
  • Two lanes, not one attribute — polite and assertive are separate elements with separate queues. Flipping a single element's aria-live races the text change it is meant to describe; an assertive interruption also must not wait behind a polite backlog.
  • Repeat guard — identical consecutive text is a no-op in the DOM and therefore silent. Appending a zero-width space to a repeat makes the text node genuinely change while the spoken sentence stays the same; a period or a counter would be read out loud. The next message drops the marker again, so it only ever rides on an actual repeat.
  • One message per gap — two writes in the same frame announce only the last one, so the queue drains on a timer. Overflow drops the oldest message: stale status is worse than no status.
  • Timed clear — announcements are events, not content. Emptying the region after clearAfter keeps a virtual-cursor reader from walking into a pile of yesterday's messages.
  • Visually hidden, not hiddensr-only (1×1, clipped, overflow:hidden) keeps the element in the accessibility tree. display:none, visibility:hidden and the hidden attribute all remove it, and with it the announcement.

On This Page