createAnnouncer
Deliver announcements through ariaNotify with a live-region fallback.
Signature and import
auto mode attempts ariaNotify when available, then falls back to the selected polite or assertive live region.
import { createAnnouncer } from "@generative-a11y/dom";
const announcer = createAnnouncer({
onDelivery(result) {
console.log(result.status, result.method);
},
});
const result = announcer.announce(intent);
// Call announcer.dispose() when the owning surface is removed.auto also falls back if ariaNotify throws. DeliveryResult describes the
browser action; disposing the announcer removes only regions it created.
Prop
Type
AnnouncerOptions
Supplying regions transfers neither node ownership nor removal responsibility to the library.
Prop
Type
The document is resolved in a fixed order: the explicit document option
first, then the owner document of the supplied polite region, then the global
document. An invalid mode throws a TypeError immediately, before any region
is created or validated.
Delivery pipeline
Use progressive delivery
In auto mode, announce tries ariaNotify first. It reads ariaNotify off
the live region element for the intent's channel and calls it with the intent
text and a priority: high for assertive intents, normal for polite ones.
When the call returns without throwing, the result is notified via
aria-notify.
Two fallback paths lead to the live region. If the element has no ariaNotify
function, delivery skips straight to mutating the region. If ariaNotify
throws, the error is serialized into the result, a latch disables ariaNotify
for all later announcements, and delivery falls back to the region as well.
Because of the latch, one failing browser means subsequent announcements skip
the ariaNotify attempt entirely instead of throwing on every message.
mode: "live-region" skips the ariaNotify attempt altogether. Use it as a
forced test path when you want deterministic region-text assertions:
const announcer = createAnnouncer({ mode: "live-region" });
const result = announcer.announce(intent);
expect(result).toMatchObject({ status: "mutated", method: "live-region" });
expect(announcer.getRegions()?.[intent.channel].textContent).toBe(
intent.text,
);Observe the attempt
Every announce call reports through onDelivery synchronously, including
unavailable and disposed results. The callback is purely observational:
exceptions thrown inside it are swallowed and cannot alter delivery. Use it to
log the status and method per announcement, or to count how often the
ariaNotify path succeeds in the browsers your users run.
const announcer = createAnnouncer({
onDelivery: (result) => {
if (result.status === "mutated" && result.error) {
console.warn("ariaNotify failed, fell back to live region:", result.error);
}
},
});The error field is only set when ariaNotify threw, so this logs exactly
the fallback cases without noise from the normal live-region path.
Release owned nodes
Call dispose() when the owning surface is removed. Disposal is idempotent.
It removes the polite and assertive regions only when the announcer created
them; regions you supplied stay in the document. After disposal, announce
returns a disposed result without touching the document.
DeliveryResult
announce returns one result synchronously.
| Status | Method | Meaning |
|---|---|---|
| notified | aria-notify | ariaNotify returned without throwing |
| mutated | live-region | Selected stable region text changed |
| unavailable | none | No usable document or region exists |
| disposed | none | announce was called after disposal |
Prop
Type
The error field is only present when ariaNotify threw and delivery fell
back to the live region. intent.locale, when defined, sets the lang
attribute on the region before delivery; an undefined locale removes it.
Supplied regions
When you pass regions, they are validated before anything else is created. A
violation throws a TypeError:
| Rule | Failure |
|---|---|
| Polite and assertive must be distinct elements | Passing the same element twice throws. |
| Both must be connected to the document | Detached elements throw. |
| Neither may contain the other | Nested regions throw. |
| Both must belong to the same document | Regions from different documents throw. |
| Both must belong to the provided document | A document option that disagrees with the regions throws. |
Supplied regions are normalized the same way owned regions are. The announcer
sets aria-live to the channel, aria-atomic="true",
aria-relevant="additions text", and applies visually-hidden styling so the
regions are exposed to assistive technology without affecting layout. It also
clears role, aria-busy, hidden, aria-hidden, and inert so a reused
container cannot accidentally silence itself.
import { createAnnouncer } from "@generative-a11y/dom";
const announcer = createAnnouncer({
regions: {
polite: document.getElementById("sr-polite")!,
assertive: document.getElementById("sr-assertive")!,
},
});Owned regions
When you do not supply regions, the announcer creates its own pair. Each
region is a div appended to document.body (falling back to the document
element when there is no body), polite first and assertive second. Both are
normalized by the same configuration applied to supplied regions: the channel
as aria-live, aria-atomic="true", aria-relevant="additions text", and
visually-hidden styling that keeps the regions exposed to assistive
technology without affecting layout.
Owned regions are the only nodes dispose() removes. If your app also needs
visible status text, keep a separate element; the announcer regions are
exclusively a screen-reader channel and are hidden from sighted users by
design. Because the regions are appended at creation time, they sit after your
app's existing content in the DOM; their position does not affect announcement
behavior.
Multiple announcers
Each createAnnouncer call is independent: it creates (or adopts) its own
region pair and its own ariaNotify latch. Two announcers on the same page do
not share state, and disposing one never touches the other's regions. Sharing
one region pair between two announcers via options.regions is allowed, but
each announcer normalizes the shared elements on creation, so prefer one
announcer per surface and dispose it with that surface.
The inert announcer
When no document is available anywhere (no document option, no supplied
regions, no global document, as in server-side rendering), createAnnouncer
does not throw. It returns an inert announcer: getRegions() returns
undefined and every announce call reports unavailable with method none.
This makes SSR safe by default; create the announcer on the client or pass a
document explicitly.
Common pitfalls
- Expecting an error without a document. The announcer stays inert and
reports
unavailable. Check the result status in code that must run during SSR. - Announcing after dispose.
announcereturns adisposedresult and leaves the document alone. Do not retain the announcer past its owner. - Assuming
ariaNotifyruns everywhere. It is progressive enhancement. Browsers without it take the live-region path, and a throwingariaNotifydisables itself after the first failure. - Reusing one element for both channels. Passing the same element as
polite and assertive throws a
TypeError. - Throwing inside
onDelivery. The error is swallowed. Keep the callback side-effect free and handle logging failures outside it. - Reading regions before creation finishes.
createAnnouncerbuilds regions synchronously, sogetRegions()is populated immediately after the call. There is no async setup step to wait for.
Evidence and testing note
A notified or mutated result confirms that the library updated the browser. Test with a real screen reader to confirm what it speaks.