0045: ARIA live-region announce on navigate
Status: Proposed Date: 2026-05-12
Context
ADR 0016 shipped manageNavFocus() —
move keyboard focus into the new page's landmark after every nav, so
AT vendors announce the focused element. ADR 0016 documented "ARIA
live region announce" as an explicit non-feature with this rationale:
No ARIA live region — apps that prefer announcing route changes via a
<div aria-live="polite">instead of moving focus can pass{ onNavigate: (url) => liveRegion.textContent = url.pathname }. Defaulting to focus-move matches the shipping ecosystem and works for both screen-reader and keyboard-only users.
The escape hatch (onNavigate callback) is correct but asks every app
that wants announce-only behavior to:
- Create the live region in their HTML or imperatively;
- Apply sr-only CSS so it doesn't disrupt layout;
- Wire
aria-live/aria-atomic/roleattributes correctly; - Handle the same-text re-announce dance (most AT vendors only re-read when the text changes, so re-navigating to the same page silently does nothing).
That's a lot of accessibility detail to push to every consumer. The patterns are well-established and stable; the helper is small enough to ship as a first-class primitive.
There are two realistic deployment shapes:
- Focus-move only (the ADR 0016 default). Works for screen-reader users (focused element announces) and keyboard-only users (the caret visibly moves into the new region). Best for typical document-style pages.
- Announce-only. Don't move focus — that's disruptive for search-heavy UIs, kiosks where the user navigates with hardware keys, switch-access setups, and any UI that wants to keep the current focus (e.g. you're typing in a query and the page updates results). Write the route's name into a live region instead so AT vendors announce without changing focus.
Some apps want both: focus-move for major navigations, announce for
in-place updates (filter changes, pagination). The two helpers
compose cleanly — they can both subscribe to onNavigate and don't
fight each other.
Decision
Ship manageNavAnnounce(options?) in @purityjs/core. Subscribes
to onNavigate() and writes the current page name into a polite
ARIA live region after every nav.
// entry.client.ts — announce-only
import { hydrate, interceptLinks, manageNavAnnounce, manageNavScroll } from '@purityjs/core';
hydrate(document.getElementById('app')!, App);
interceptLinks();
manageNavScroll();
manageNavAnnounce();
Concretely:
manageNavAnnounce(options?: ManageNavAnnounceOptions): () => void— registers an announce handler. Returns a teardown. No-op on the server.- Default message is
document.title(read after a microtask, sohead()/manageTitle()writes have flushed); falls back tourl.pathnamewhen title is empty or whitespace-only. - Default region is created lazily on first navigate. Inline
sr-only styles (
position:absolute; width:1px; height:1px; … clip:rect(0,0,0,0)) keep it out of the visual layout. Default id'__purity_announce__'. regionIdoption points at an existing element when the app ships its own region (with custom styles or placement). Existing elements are reused; the helper doesn't overwrite theiraria-live/roleattributes.liveoption picks'polite'(default) or'assertive'. Affects the auto-created region'saria-livevalue and pairs it with the conventionalrole('status'for polite,'alert'for assertive). User-authored regions keep their own attributes.messageoption is the strongest customisation point —(url, replace) => string. Replaces the default title-lookup with whatever string the app wants to announce (e.g. localised page names,"Loading users...","Page ${n} of ${total}").onNavigateoption is the full escape hatch. Replaces the default handler entirely; the helper just subscribes the callback toonNavigate(with the microtask defer).regionId/live/messageare all ignored when supplied.- Microtask defer matches
manageNavFocus/manageNavScrollso route-mounted DOM exists before the announce text is computed (specifically:document.titleis whatevermanageTitlewrote during this nav, not the stale previous-page title). - Same-text re-announce — when the new message is identical to
the existing
textContent, clear it for one microtask and write it back. Most AT vendors only re-read on change; this is the documented workaround used across the ecosystem.
The new key on configureNavigation() is announce — same
true / false / options-object semantics as the other four. Off by
default (focus is the recommended baseline; announce is the
alternative posture).
Explicit non-features
- Removing the region on teardown. The region stays in the DOM
after
teardown(). Apps that recreate the announce helper (HMR, spec runs) would otherwise repeatedly create + destroy DOM nodes; reuse is cheaper and the user-visible effect is zero (the region is sr-only). - Announce route data / loader payload. The message function
receives
(url, replace); apps that want richer announces wire their own logic. We don't want a framework-level "what to announce" policy. - Multi-region announce. Some apps want both polite + assertive
regions and split announces between them. That's a custom
onNavigatehandler. The default helper picks one region. - Combined focus + announce coordination. Some patterns layer
focus-move with a delayed announce (focus first, announce 300ms
later if the user hasn't interacted). Out of scope. Compose
manageNavFocus()+manageNavAnnounce()with custom callbacks for that level of orchestration. <title>synchronisation. Already shipped — ADR 0030'smanageTitle(). Pairs with this helper out of the box (default message readsdocument.title).
Consequences
Positive:
- Closes the announce-only accessibility gap that ADR 0016
punted to "user-land via
onNavigatecallback". Apps that want the pattern no longer hand-roll the live region + sr-only CSS + same-text re-announce dance. - Composes with
manageTitle()(ADR 0030): apps usingmanageTitleget the page title announced automatically without writing any glue code. - Composes with
manageNavFocus()(ADR 0016) for apps that want both: focus moves visually + audio announce. Microtask defer ordering is identical so both see the same DOM state. - New
announcekey onconfigureNavigation()keeps the one-call setup story coherent. Defaultfalsematches "announce is the alternative posture, focus is the recommended baseline".
Negative:
- One more opt-in primitive in
@purityjs/core. The router surface now has fivemanageNav*helpers (scroll, focus, transitions, prefetch, announce). Apps usingconfigureNavigation()see them as one consolidated option; standalone-import apps see a longer list. Tree-shaking is intact — unused helpers don't ship. - The auto-created region is a DOM mutation outside the user's
control. Inline sr-only style is visible in dev tools. Apps that
want fully-controlled DOM use
regionIdpointing at their own element.
Neutral:
- Two new exports (
manageNavAnnounce,ManageNavAnnounceOptions). - The same-text re-announce path uses three microtasks (queue navigate callback → clear → restore). Negligible cost; happens only on identical-text re-navigates.
Alternatives considered
Default behavior: announce + focus together. Both helpers always run; users opt out individually. Rejected: too prescriptive. Some apps actively don't want focus-move (kiosks, search-heavy UIs); defaulting them on would surprise.
Use aria-live on <main> directly instead of a separate region.
Some patterns set aria-live="polite" on the page content itself.
Rejected: every route-driven DOM update would announce, including
reactive content the user didn't trigger. The dedicated region scopes
announces to nav events only.
Read from document.title synchronously rather than after a
microtask. Simpler timing model. Rejected: when paired with
manageTitle(), the title is set inside the reactive watch which
flushes after the current task. Reading sync would announce the
previous page's title.
Generate the announce string from route metadata rather than
document.title. E.g. require an <meta name="purity-announce">
or look up a manifest entry. Rejected: framework-level coupling. The
message option already lets apps do this when they want it.
Remove the auto-created region on teardown. Symmetric with
create-on-first-use. Rejected: HMR cycles would thrash the DOM; the
sr-only region is invisible at rest so leaving it in place is the
right trade. Users who want symmetric semantics can call
document.getElementById('__purity_announce__')?.remove() in their
own teardown.
Test surface
packages/core/tests/router-announce.test.ts:
- Region creation: default id, sr-only styles, polite + role="status"; assertive + role="alert"; reuse existing element by id; lazy-on-first-navigate.
- Message text: announces
document.titlewhen set; falls back tourl.pathnamewhen title empty / whitespace-only; custommessage;replaceflag is passed through. - Same-text re-announce: identical message triggers clear-then-set.
- Custom
onNavigate: replaces default entirely, no auto-region. - Teardown: stops announcing, leaves region in DOM.
- Server: no-op when
documentis undefined.
packages/core/tests/router-configure.test.ts:
announce: trueenables the helper.announce: { … }forwards options.- Default is off (no region created without opt-in).