@pyreon/a11y is the accessibility foundation the rest of Pyreon builds on. It ships five small, zero-config primitives — imperative announcements, a declarative live region, visually-hidden content, a skip link, and stable ARIA ids — plus a router subpath for SPA route announcements. Every primitive is SSR-safe and works with no provider and nothing to mount (announcements lazily create their own live region the toast way).
This is the API reference for the package. For the cross-cutting picture — how form label/error wiring, modal focus trap, primitive keyboard navigation, the multi-platform accessibilityLabel vocabulary, reduced-motion, and the opt-in a11y lint rules fit together — read the Accessibility guide.
Installation
npm install @pyreon/a11ybun add @pyreon/a11ypnpm add @pyreon/a11yyarn add @pyreon/a11yAt a glance
| Primitive | Use it for | Import |
|---|---|---|
announce(message) | Fire-and-forget status / error spoken to screen readers | @pyreon/a11y |
<LiveRegion> | Persistent, signal-driven status that lives in your layout | @pyreon/a11y |
<VisuallyHidden> | Labels / headings invisible on screen but read aloud | @pyreon/a11y |
<SkipLink> | Keyboard "skip to content" past repeated nav (WCAG 2.4.1) | @pyreon/a11y |
createA11yId(prefix?) | Stable, SSR-safe ids for aria-labelledby / aria-describedby / for | @pyreon/a11y |
<RouteAnnouncer> / useRouteAnnouncer() | Announce client-side route changes | @pyreon/a11y/router |
import { announce, VisuallyHidden, LiveRegion, SkipLink, createA11yId } from '@pyreon/a11y'
import { RouteAnnouncer, useRouteAnnouncer } from '@pyreon/a11y/router'announce() — screen-reader announcements
Speak a message to screen-reader users through an aria-live region — with zero setup. The first call lazily creates a visually-hidden region on document.body and reuses it across the page lifetime, so there is no provider and no <Announcer> to mount (the same philosophy as @pyreon/toast).
import { announce } from '@pyreon/a11y'
announce('Settings saved') // polite (default)
announce('Connection lost', { politeness: 'assertive' }) // interrupts
announce('Copied to clipboard', { clearAfter: 1000 }) // auto-empty after 1sSignature
function announce(
message: string,
options?: {
politeness?: 'polite' | 'assertive' // default 'polite'
clearAfter?: number // ms; omit to leave the message in place
},
): void| Option | Default | Notes |
|---|---|---|
politeness | 'polite' | 'polite' queues the message for when the user is idle (status updates, "saved", result counts). 'assertive' interrupts immediately — reserve it for errors and time-critical alerts. |
clearAfter | — | Clear the region this many ms after announcing, so stale text isn't re-read if the user navigates back into the region. Omit to leave the message in the DOM. |
How it behaves
One region per politeness. A
politeregion (role="status") and anassertiveregion (role="alert") are created on demand, eacharia-atomic="true", and reused.Identical repeats still announce. The region is cleared first and the message written on the next animation frame, so calling
announce('Saving…')twice in a row is re-read — an unchangedtextContentwould otherwise be silent in many screen readers.SSR-safe. On the server
announce()is a no-op (there is no live region to write to). Announcements are inherently client-side, user-triggered events — call them from handlers and effects, never during render.
clearAnnouncements()
Remove the lazily-created live regions from the DOM. Primarily for tests and single-page teardown — application code rarely needs it, since the regions are tiny, hidden, and reused.
import { clearAnnouncements } from '@pyreon/a11y'
afterEach(() => clearAnnouncements())Common mistakes
Using
assertivefor routine status. It interrupts whatever the screen reader is saying; overuse is hostile. Defaultpolitequeues politely — keepassertivefor errors and alerts.Calling
announce()during SSR and expecting output. It is a no-op on the server. Trigger it in event handlers or effects.Expecting visible UI. The region is hidden; render your own visible status separately.
<LiveRegion> — declarative reactive announcements
announce() is fire-and-forget and global; <LiveRegion> is the persistent, reactive complement — a region you own and position in your layout. Drive its children with a signal and the browser announces every content change automatically. No announce() call, no effect to wire — the live-region machinery observes the DOM mutation for you.
import { LiveRegion } from '@pyreon/a11y'
// Reactive status — announced on every change, zero wiring:
<LiveRegion>{() => status()}</LiveRegion>
// A visible "Saving…" line that ALSO announces:
<LiveRegion visible>{() => saveState()}</LiveRegion>
// Errors interrupt:
<LiveRegion politeness="assertive">{() => error()}</LiveRegion>Props
| Prop | Type | Default | Notes |
|---|---|---|---|
politeness | 'polite' | 'assertive' | 'off' | 'polite' | 'off' keeps the region mounted but silences it, so you can toggle reactively (politeness={() => muted() ? 'off' : 'polite'}) without unmounting. |
atomic | boolean | true | Announce the WHOLE region on change. Set false with role="log" for append-only feeds where only the newest entry should be read. |
role | 'status' | 'alert' | 'log' | auto | Defaults to 'status' for polite, 'alert' for assertive, omitted for 'off'. Pass 'log' for an append-only region. |
visible | boolean | false | Render visibly instead of screen-reader-only — for status text that should also be seen. |
children | VNodeChild | — | The announced content. Read it inside an accessor ({() => status()}) so the region tracks the signal. |
Any other props (id, class, aria-*, …) are forwarded to the element.
announce() vs <LiveRegion>
announce() | <LiveRegion> | |
|---|---|---|
| Shape | Imperative function call | Declarative component |
| Region | One shared, global on document.body | One you own and position |
| Wiring | Call it from a handler/effect | Drive children with a signal — no call |
| SSR | No-op on the server | Renders the region server-side, so the first reactive update after hydration is announced |
Reach for announce() for fire-and-forget events with no home in the layout (a clipboard copy, an async result toast). Reach for <LiveRegion> when the status already lives somewhere specific — a form's validation summary, a connection banner, a "Saving…" → "Saved" indicator.
Common mistakes
Reaching for
announce()in an effect when the status already lives in your tree.<LiveRegion>{() => status()}</LiveRegion>announces on change with zero wiring.Mounting/unmounting the region to turn it off. Toggle
politeness="off"instead so the element stays stable.Reading children eagerly (
{status()}without the accessor). Pass{() => status()}so the region tracks the signal.
<VisuallyHidden> — invisible on screen, read by screen readers
Render content that is invisible to sighted users but kept in the accessibility tree — for labels, headings, and status text that sighted users get from visual context but assistive-tech users need spelled out. Unlike display:none or the hidden attribute, the content stays readable to screen readers.
import { VisuallyHidden } from '@pyreon/a11y'
// A "Search" label on an icon-only button:
<button>
<SearchIcon />
<VisuallyHidden>Search</VisuallyHidden>
</button>
// An off-screen heading that structures a landmark:
<section aria-labelledby="filters-h">
<VisuallyHidden as="h2" id="filters-h">Filters</VisuallyHidden>
…
</section>Props
| Prop | Type | Default | Notes |
|---|---|---|---|
as | string | 'span' | Tag to render. Use a block tag ('div', 'h2') where inline flow would be wrong. |
children | VNodeChild | — | The hidden content. |
Other props (id, class, aria-*, …) are forwarded. A style object merges over the clipping base, so an explicit property wins while the defaults still apply for anything you omit.
Common mistakes
Using
display:none/hiddeninstead. Those remove content from the accessibility tree, so screen readers never see it.VisuallyHiddenclips it but keeps it readable.Putting interactive controls inside it. A visually-hidden focusable element is a keyboard trap for sighted keyboard users (focus jumps to invisible content). Keep it to non-interactive text.
<SkipLink> — keyboard "skip to content"
The first focusable element on the page, hidden until focused, that lets keyboard and screen-reader users jump past repeated navigation straight to the main content (WCAG 2.4.1 — Bypass Blocks). It's clipped out of view until it receives focus on the first Tab, then appears at the top-left; activating it moves both scroll and keyboard focus to the target landmark, so the next Tab continues from the main content.
import { SkipLink } from '@pyreon/a11y'
<body>
<SkipLink href="#main">Skip to content</SkipLink>
<nav>…</nav>
<main id="main">…</main>
</body>Props
| Prop | Type | Default | Notes |
|---|---|---|---|
href | string | '#main' | In-page fragment to skip to. Point it at your main landmark. |
children | VNodeChild | 'Skip to content' | Link text. |
Other props (class, id, style, on*, …) are forwarded to the <a>. A style object merges over the built-in reveal styles, so you can restyle the focused appearance without losing the hide-until-focus behavior. (The defaults are neutral and use currentColor for the border.)
How it behaves
Hidden but focusable when not focused — clipped to a 1px box, kept in the DOM and tab order.
Revealed on focus at the top-left (
position: fixed), then clipped again on blur.Moves focus, not just scroll. Activating it adds a programmatic-focus
tabindex="-1"to a non-focusable target (such as<main>) and calls.focus(), so the next Tab continues from the content.
Common mistakes
Not rendering it first. A skip link only works if it is the first focusable element, so the very first Tab reveals it. Put it at the top of
<body>/ the app root, before nav.Pointing
hrefat a non-existent id. If#mainhas no matching element, nothing moves. Ensure the target landmark carries the id.Hiding it with
display:none/hidden. That removes it from the tab order so it can never be focused.SkipLinkclips it (stays focusable) and reveals it on focus — don't override that.
createA11yId() — stable, SSR-safe ARIA ids
Generate a stable, SSR-safe unique id for ARIA relationship attributes (aria-labelledby, aria-describedby, aria-controls, for/id pairing). It wraps @pyreon/core's createUniqueId() so the same id is produced on the server and rehydrated on the client — no mismatch.
import { createA11yId } from '@pyreon/a11y'
function Field() {
const labelId = createA11yId('label')
const inputId = createA11yId('input')
return (
<>
<span id={labelId}>Email</span>
<input id={inputId} aria-labelledby={labelId} />
</>
)
}Signature
function createA11yId(prefix?: string): string // prefix default 'px-a11y'The prefix is cosmetic — it makes the generated id readable when inspecting the DOM but has no functional effect.
Common mistakes
Calling it at module scope and sharing one id across many instances. Call it inside the component so each instance gets its own id.
Hardcoding ids instead. Duplicate ids across a page break
aria-labelledby/aria-describedbyresolution;createA11yIdguarantees uniqueness.
Route announcements — @pyreon/a11y/router
Single-page navigations are invisible to screen readers: the URL and DOM change but no page-load event fires, so assistive tech never announces "you are now on <page>". This is the canonical SPA accessibility gap. <RouteAnnouncer> (and the useRouteAnnouncer() hook) close it — they register one router afterEach hook that pushes the destination route's title to a polite live region via announce().
import { RouteAnnouncer } from '@pyreon/a11y/router'
<RouterProvider router={router}>
<RouteAnnouncer />
<RouterView />
</RouterProvider>Or the hook form, from a long-lived component (typically the root layout):
import { useRouteAnnouncer } from '@pyreon/a11y/router'
useRouteAnnouncer() // announces meta.title, else the path
useRouteAnnouncer({ format: (to) => `${to.meta.title ?? to.path} page` })
useRouteAnnouncer({ politeness: 'assertive', clearAfter: 1000 })Options
| Option | Type | Default | Notes |
|---|---|---|---|
format | (to, from) => string | null | title-or-path | Build the announced string from the destination route. Return null / undefined to skip announcing a given navigation. The default reads to.meta.title, falling back to "Navigated to <path>". |
politeness | 'polite' | 'assertive' | 'polite' | Live-region politeness. |
clearAfter | number | — | Clear the announcement this many ms after it fires. |
announceInitial | boolean | false | Also announce the route present at mount. Usually unwanted — on first load the screen reader already reads the freshly-loaded page. Enable it only when the announcer mounts after the initial navigation already committed (e.g. a deferred app shell). |
Why a subpath
@pyreon/a11y is a fundamentals package and may depend on @pyreon/router (the correct fundamentals → core direction), but the router dependency lives only in the /router subpath. Importing just announce / VisuallyHidden / createA11yId from the main entry never pulls the router into your bundle (the same split as @pyreon/i18n vs @pyreon/i18n/core).
Common mistakes
Importing it from
@pyreon/a11yinstead of@pyreon/a11y/router. The router integration lives in the subpath so the base entry stays router-free.Mounting more than one. Each registers its own
afterEachhook, so the route gets announced N times. Mount exactly one, in a component that lives for the app's lifetime.Expecting it to announce the initial page load. It doesn't by default (a redundant announcement on a freshly-loaded page is noise). Set
announceInitialonly for the deferred-shell case.Relying on it for
<head>-driven dynamic titles. The default readsroute.meta.title; if you set titles at runtime via@pyreon/head, pass aformatcallback that returns the live title.
SSR & hydration
Every primitive is built for server rendering:
announce()andclearAnnouncements()are no-ops on the server (announcements are client-side events).useRouteAnnounceronly registers its hook inonMount, so it never runs during SSR.<VisuallyHidden>,<LiveRegion>, and<SkipLink>render plain elements with their ARIA attributes — no DOM access at setup — so they're present at hydration. A<LiveRegion>therefore exists before the first reactive update, and that update is announced.createA11yId()produces the same value on the server and the client, soaria-labelledby/aria-describedbywiring never breaks across hydration.
See also
The Accessibility guide — the cross-cutting map of out-of-the-box a11y across Pyreon: form label/error auto-wiring, modal focus trap and restore, primitive keyboard navigation, the multi-platform
accessibilityLabel/accessibilityHiddenvocabulary, reduced-motion, typed ARIA roles, and the opt-in a11y lint rules.@pyreon/toast— visible notifications that pair withrole="alert"/aria-liveout of the box.