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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/announcer.jsonPrompt
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-liveraces 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
clearAfterkeeps a virtual-cursor reader from walking into a pile of yesterday's messages. - Visually hidden, not hidden —
sr-only(1×1, clipped,overflow:hidden) keeps the element in the accessibility tree.display:none,visibility:hiddenand thehiddenattribute all remove it, and with it the announcement.
Passkey Prompt
A passkey enrolment card that asks the device what it can do before offering anything, runs the WebAuthn ceremony you inject through an AbortSignal, and treats a closed sheet as a retriable answer instead of an error.
Dismissible
A wrapper that adds a close button to any content and remembers it was closed — for this tab, forever, or until the copy is versioned up — with an undo window before anything is written.