pyreon

@pyreon/a11y — API Reference

Generated from a11y's src/manifest.ts — the same source that powers llms.txt and MCP get_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see a11y.

Zero-setup accessibility building blocks for Pyreon. announce(message) speaks status updates and errors to screen readers via an aria-live region that is created lazily on first call — no provider, no component to mount, SSR-safe (no-op on the server). <VisuallyHidden> renders content that is invisible on screen but kept in the accessibility tree (unlike display:none). <LiveRegion> is the declarative complement to announce() — a persistent aria-live region you place in your tree and drive with a signal, so screen readers announce every content change with zero wiring. createA11yId(prefix?) produces stable, SSR-safe ids for ARIA relationship attributes (aria-labelledby / aria-describedby / for). The shared foundation other Pyreon packages build on for out-of-the-box accessibility.

Features

  • announce(message) — speak status/errors to screen readers via a lazily-created aria-live region; zero setup, no provider, SSR-safe no-op

  • polite (default, queued) and assertive (interrupts) politeness; clearAfter to auto-empty stale text; identical repeats re-announced via clear-then-set

  • <VisuallyHidden> — content invisible on screen but kept in the accessibility tree (unlike display:none)

  • <LiveRegion> — declarative aria-live region; drive its children with a signal and screen readers announce every change (the persistent, reactive complement to imperative announce())

  • <SkipLink> — keyboard "skip to content" link, hidden until focused; moves scroll AND focus to the main landmark past repeated nav (WCAG 2.4.1 Bypass Blocks)

  • createA11yId(prefix?) — stable SSR-safe ids for aria-labelledby / aria-describedby / for relationships

  • <RouteAnnouncer> / useRouteAnnouncer() (@pyreon/a11y/router) — announce client-side route changes to screen readers (the SPA navigation gap); one router afterEach hook → polite live region

Complete example

A full, end-to-end usage of the package:

import { announce, VisuallyHidden, createA11yId } from '@pyreon/a11y'

// Speak a message to screen-reader users — no provider, no setup:
announce('Settings saved')
announce('Connection lost', { politeness: 'assertive' }) // interrupts
announce('Copied', { clearAfter: 1000 })

// Visually-hidden but screen-reader-accessible content:
function IconButton() {
  return (
    <button>
      <SearchIcon />
      <VisuallyHidden>Search</VisuallyHidden>
    </button>
  )
}

// Stable SSR-safe id to wire ARIA relationships:
function Field() {
  const hintId = createA11yId('hint')
  return (
    <>
      <input aria-describedby={hintId} />
      <span id={hintId}>Must be at least 8 characters</span>
    </>
  )
}

Exports

SymbolKindSummary
announcefunctionSpeak a message to screen readers via an aria-live region.
VisuallyHiddencomponentRender content invisible on screen but kept in the accessibility tree (unlike display:none / the hidden attribute).
LiveRegioncomponentA declarative aria-live region — the persistent, reactive complement to imperative announce().
SkipLinkcomponentA keyboard "skip to content" link (WCAG 2.4.1 Bypass Blocks): render it as the first focusable element on the page.
createA11yIdfunctionGenerate a stable, SSR-safe unique id for ARIA relationship attributes (aria-labelledby / aria-describedby / aria-contro
RouteAnnouncercomponentAnnounce client-side route changes to screen-reader users — the canonical SPA accessibility gap (single-page navigations
useRouteAnnouncerfunctionHook form of <RouteAnnouncer>.

API

announce function

announce(message: string, options?: { politeness?: "polite" | "assertive"; clearAfter?: number }): void

Speak a message to screen readers via an aria-live region. Lazily creates a visually-hidden region on document.body on first call and reuses it — zero setup, no provider. Clears the region then writes on the next frame so two identical consecutive messages still re-announce. No-op on the server.

Example

announce('Item added to cart')
announce('Error: name is required', { politeness: 'assertive' })

Common mistakes

  • Using assertive for routine status updates — it interrupts whatever the screen reader is saying. Reserve it for errors and time-critical alerts; default polite queues politely.

  • Calling announce() during SSR expecting output — it is a no-op on the server. Announcements are client-side, user-triggered events; trigger them in handlers / effects, not render.

  • Expecting visible UI — the live region is visually hidden by design. Render your own visible toast/status separately; announce() is the screen-reader channel.


VisuallyHidden component

function VisuallyHidden(props: { as?: string; children?: VNodeChild; [key: string]: unknown }): VNodeChild

Render content invisible on screen but kept in the accessibility tree (unlike display:none / the hidden attribute). For labels and status text sighted users get from visual context but assistive-tech users need spelled out. Defaults to a <span>; pass as for a different tag. Caller styles merge over the clipping base.

Example

<button><SearchIcon /><VisuallyHidden>Search</VisuallyHidden></button>

Common mistakes

  • Using display:none or the hidden attribute instead — those remove the 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.


LiveRegion component

function LiveRegion(props: { politeness?: "polite" | "assertive" | "off"; atomic?: boolean; role?: "status" | "alert" | "log"; visible?: boolean; children?: VNodeChild; [key: string]: unknown }): VNodeChild

A declarative aria-live region — the persistent, reactive complement to imperative announce(). Place it once in your tree and drive its children with a signal; the browser announces every content change automatically (no announce() call, no effect). Screen-reader-only by default (reuses VisuallyHidden clipping); pass visible for status text that should also be seen. Defaults: politeness "polite" → role "status", "assertive" → role "alert", "off" silences without a contradictory implicit role. Renders on the server too, so the region exists at hydration and the first reactive update is announced.

Example

<LiveRegion>{() => status()}</LiveRegion>
<LiveRegion politeness="assertive">{() => error()}</LiveRegion>
<LiveRegion visible>{() => saveState()}</LiveRegion>

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. Use announce() for fire-and-forget events with no home in the layout.

  • Using assertive for routine status — it interrupts the screen reader. Reserve it for errors; default polite queues politely.

  • Mounting/unmounting the region to "turn it off" — toggle politeness="off" instead so the element (and its announce history) stays stable.

  • Reading the children eagerly (e.g. {status()} without the accessor) — pass {() => status()} so the region tracks the signal and re-announces on change.


function SkipLink(props: { href?: string; children?: VNodeChild; [key: string]: unknown }): VNodeChild

A keyboard "skip to content" link (WCAG 2.4.1 Bypass Blocks): render it as the first focusable element on the page. It is clipped out of view until it receives focus (first Tab), then appears at the top-left; activating it moves BOTH scroll and keyboard focus to the target landmark (default #main), so the next Tab continues from the main content. Adds a programmatic-focus tabindex to a non-focusable target automatically. A style object merges over the built-in reveal styles to restyle the focused appearance without losing the hide-until-focus behavior.

Example

<SkipLink href="#main">Skip to content</SkipLink>
<nav></nav>
<main id="main"></main>

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 (e.g. <main id="main">).

  • Hiding it with display:none / the hidden attribute to keep it off-screen — 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 with display:none.


createA11yId function

createA11yId(prefix?: string): string

Generate a stable, SSR-safe unique id for ARIA relationship attributes (aria-labelledby / aria-describedby / aria-controls / for). Wraps @pyreon/core's createUniqueId so server and client agree — no hydration mismatch. The optional prefix is cosmetic (DOM-inspection readability).

Example

const id = createA11yId('hint')
<input aria-describedby={id} /><span id={id}></span>

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, or two elements will collide.

  • Hardcoding ids instead — duplicate ids across a page break aria-labelledby/describedby resolution. createA11yId guarantees uniqueness.


RouteAnnouncer component

import { RouteAnnouncer } from '@pyreon/a11y/router'
function RouteAnnouncer(props?: { format?: (to, from) => string | null; politeness?: 'polite' | 'assertive'; clearAfter?: number; announceInitial?: boolean }): null

Announce client-side route changes to screen-reader users — the canonical SPA accessibility gap (single-page navigations change the URL + DOM but fire no page-load event, so assistive tech never says "you are now on <page>"). Renders nothing; registers ONE router afterEach hook that pushes the destination route's meta.title (or "Navigated to <path>") to a polite aria-live region via announce(). Drop one near the router root. Imported from the @pyreon/a11y/router subpath so the base @pyreon/a11y entry stays router-free.

Example

<RouterProvider router={router}>
  <RouteAnnouncer />
  <RouterView />
</RouterProvider>

Common mistakes

  • Importing it from @pyreon/a11y instead of @pyreon/a11y/router — the router integration lives in the subpath so the base entry never pulls @pyreon/router into bundles that only use announce()/VisuallyHidden.

  • 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 (the root layout).

  • Expecting it to announce the initial page load — it does not by default (the screen reader already reads a freshly-loaded page; a redundant announcement is noise). Set announceInitial only when the announcer mounts after the first navigation already committed.

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


useRouteAnnouncer function

import { useRouteAnnouncer } from '@pyreon/a11y/router'
useRouteAnnouncer(options?: RouteAnnouncerOptions): void

Hook form of <RouteAnnouncer>. Call once from a long-lived component to announce route changes. Registers a single router afterEach hook (auto-removed on unmount) and announces via the zero-setup announce() polite live region. SSR-safe — the hook only registers in onMount and announce() no-ops on the server.

Example

useRouteAnnouncer({ format: (to) => `${to.meta.title ?? to.path} page` })

Common mistakes

  • Calling it in a component that mounts/unmounts per navigation — the hook would re-register each time. Call it in the persistent root layout, not a per-route page.

  • Using it without a <RouterProvider> in scope — it calls useRouter(), which throws if no router is installed.


Accessibility Primitives — API Reference