pyreon

@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).

@pyreon/a11ystable

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/a11y
bun add @pyreon/a11y
pnpm add @pyreon/a11y
yarn add @pyreon/a11y

At a glance

PrimitiveUse it forImport
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 1s

Signature

function announce(
  message: string,
  options?: {
    politeness?: 'polite' | 'assertive' // default 'polite'
    clearAfter?: number                 // ms; omit to leave the message in place
  },
): void
OptionDefaultNotes
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.
clearAfterClear 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 polite region (role="status") and an assertive region (role="alert") are created on demand, each aria-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 unchanged textContent would 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 assertive for routine status. It interrupts whatever the screen reader is saying; overuse is hostile. Default polite queues politely — keep assertive for 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

PropTypeDefaultNotes
politeness'polite' | 'assertive' | 'off''polite''off' keeps the region mounted but silences it, so you can toggle reactively (politeness={() => muted() ? 'off' : 'polite'}) without unmounting.
atomicbooleantrueAnnounce 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'autoDefaults to 'status' for polite, 'alert' for assertive, omitted for 'off'. Pass 'log' for an append-only region.
visiblebooleanfalseRender visibly instead of screen-reader-only — for status text that should also be seen.
childrenVNodeChildThe 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>
ShapeImperative function callDeclarative component
RegionOne shared, global on document.bodyOne you own and position
WiringCall it from a handler/effectDrive children with a signal — no call
SSRNo-op on the serverRenders 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

PropTypeDefaultNotes
asstring'span'Tag to render. Use a block tag ('div', 'h2') where inline flow would be wrong.
childrenVNodeChildThe 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 / hidden instead. Those remove content from the accessibility tree, so screen readers never see it. VisuallyHidden clips 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.

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

PropTypeDefaultNotes
hrefstring'#main'In-page fragment to skip to. Point it at your main landmark.
childrenVNodeChild'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 href at a non-existent id. If #main has 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. SkipLink clips 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-describedby resolution; createA11yId guarantees 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

OptionTypeDefaultNotes
format(to, from) => string | nulltitle-or-pathBuild 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.
clearAfternumberClear the announcement this many ms after it fires.
announceInitialbooleanfalseAlso 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/a11y instead 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 afterEach hook, 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 announceInitial only for the deferred-shell case.

  • Relying on it for <head>-driven dynamic titles. The default reads route.meta.title; if you set titles at runtime via @pyreon/head, pass a format callback that returns the live title.

SSR & hydration

Every primitive is built for server rendering:

  • announce() and clearAnnouncements() are no-ops on the server (announcements are client-side events). useRouteAnnouncer only registers its hook in onMount, 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, so aria-labelledby / aria-describedby wiring 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 / accessibilityHidden vocabulary, reduced-motion, typed ARIA roles, and the opt-in a11y lint rules.

  • @pyreon/toast — visible notifications that pair with role="alert" / aria-live out of the box.

Accessibility primitives (a11y)