generative-a11y
DOM

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.

announcer.ts
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:

announcer.test.ts
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.

delivery-logging.ts
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.

StatusMethodMeaning
notifiedaria-notifyariaNotify returned without throwing
mutatedlive-regionSelected stable region text changed
unavailablenoneNo usable document or region exists
disposednoneannounce 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:

RuleFailure
Polite and assertive must be distinct elementsPassing the same element twice throws.
Both must be connected to the documentDetached elements throw.
Neither may contain the otherNested regions throw.
Both must belong to the same documentRegions from different documents throw.
Both must belong to the provided documentA 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.

supplied-regions.ts
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. announce returns a disposed result and leaves the document alone. Do not retain the announcer past its owner.
  • Assuming ariaNotify runs everywhere. It is progressive enhancement. Browsers without it take the live-region path, and a throwing ariaNotify disables 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. createAnnouncer builds regions synchronously, so getRegions() 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.