Accessibility
Pyreon is built so that accessible UI is the default, not an afterthought. Most of the work — label/error wiring, focus management, keyboard navigation, correct ARIA serialization — happens automatically when you use the framework's primitives. Where it can't be automatic, it's a one-line opt-in.
This page is the map: what you get for free, and how to reach for the rest.
What you get out of the box
| Concern | How it's covered | Where |
|---|---|---|
Form labels / errors / aria-describedby | Auto-wired by useField / <Form> | @pyreon/form |
| Modal focus trap + restore | Automatic for modal overlays | @pyreon/elements, @pyreon/ui-primitives |
| Keyboard navigation | Built into every interactive headless primitive | @pyreon/ui-primitives |
| Reduced motion | Animations auto-respect prefers-reduced-motion | @pyreon/kinetic |
| Correct ARIA serialization | Boolean aria-* render as "true"/"false", not presence-only | @pyreon/runtime-dom, @pyreon/runtime-server |
Typed role | AriaRole autocomplete on every element | @pyreon/core |
| Live-region announcements | announce() — zero setup | @pyreon/a11y |
| SPA route announcements | <RouteAnnouncer> — one line | @pyreon/a11y/router |
| Multi-platform a11y | accessibilityLabel / accessibilityHidden / accessibilityRole → web + iOS + Android | @pyreon/primitives |
Forms — automatic label, error, and description wiring
@pyreon/form's useField / <Form> generate the ARIA relationships every accessible form needs — you don't hand-wire id / for / aria-describedby / aria-invalid:
import { Form, useField } from '@pyreon/form'
function EmailField() {
// register() returns a stable id + reactive aria-invalid / aria-describedby
const { inputProps, labelProps, errorProps, showError } = useField('email')
return (
<div>
<label {...labelProps}>Email</label>
<input {...inputProps} />
{() => showError() && <span {...errorProps()}>Enter a valid email</span>}
</div>
)
}The label is associated with the input, the error is linked via aria-describedby, and aria-invalid flips reactively — all from useField('email').
Screen-reader announcements
announce() — status & errors, zero setup
announce(message) speaks to screen-reader users through an aria-live region that's created lazily on first call. No provider, no component to mount, SSR-safe (a no-op on the server):
import { announce } from '@pyreon/a11y'
announce('Settings saved') // polite (default)
announce('Connection lost', { politeness: 'assertive' }) // interrupts
announce('Copied to clipboard', { clearAfter: 1000 })Use it for things sighted users perceive visually but assistive-tech users need spoken: a saved toast, a validation error, a "5 results" count.
<RouteAnnouncer> — single-page navigations
Client-side navigations change the URL and DOM but fire no page-load event, so screen readers never announce the new page. Drop one <RouteAnnouncer> near your router root to close that gap:
import { RouteAnnouncer } from '@pyreon/a11y/router'
<RouterProvider router={router}>
<RouteAnnouncer />
<RouterView />
</RouterProvider>It announces each route's meta.title (or "Navigated to <path>") to a polite live region. Customise with format, or use the useRouteAnnouncer() hook form. The router dependency lives only in the @pyreon/a11y/router subpath, so importing just announce / VisuallyHidden from the main entry stays router-free.
<LiveRegion> — declarative status
announce() is fire-and-forget; <LiveRegion> is the declarative complement for status that lives somewhere specific in your layout — a form's validation summary, a "Saving…" → "Saved" indicator, an async result count. Place it once and drive its children with a signal; the browser announces every change automatically — no announce() call, no effect:
import { LiveRegion } from '@pyreon/a11y'
<LiveRegion>{() => status()}</LiveRegion> // polite (default → role="status")
<LiveRegion politeness="assertive">{() => error()}</LiveRegion> // interrupts (→ role="alert")
<LiveRegion visible>{() => saveState()}</LiveRegion> // also shown on screenScreen-reader-only by default (it reuses VisuallyHidden's clipping), visible opts into on-screen text, and politeness="off" silences it without unmounting. It renders on the server too, so the region exists at hydration and the very first reactive update is announced. Read it inside the accessor ({() => status()}) so the region tracks the signal.
Visually-hidden content & stable IDs
import { VisuallyHidden, createA11yId } from '@pyreon/a11y'
// Off-screen but in the accessibility tree (unlike display:none):
<button><SearchIcon /><VisuallyHidden>Search</VisuallyHidden></button>
// Stable, SSR-safe ids for ARIA relationships (no hydration mismatch):
const hintId = createA11yId('hint')
<input aria-describedby={hintId} />
<span id={hintId}>Must be at least 8 characters</span>Skip to content (WCAG 2.4.1)
Render <SkipLink> as the first focusable element on the page. It's clipped out of view until a keyboard user presses Tab, then appears at the top-left; activating it moves both scroll and keyboard focus to your main landmark, so the next Tab continues from the content — past the repeated nav.
import { SkipLink } from '@pyreon/a11y'
<body>
<SkipLink href="#main">Skip to content</SkipLink>
<nav>…</nav>
<main id="main">…</main>
</body>It adds a programmatic-focus tabindex to the target automatically when it isn't natively focusable, so a plain <main id="main"> just works. Restyle the focused appearance by passing a style object (it merges over the built-in reveal styles); the hide-until-focus behavior stays intact.
Focus management — modals trap & restore automatically
A modal that doesn't trap focus lets keyboard and screen-reader users tab out to the inert background. Pyreon's modal surfaces handle the full WAI-ARIA dialog focus lifecycle for you:
<Overlay type="modal">(@pyreon/elements) and the headlessModalBase(@pyreon/ui-primitives) move focus into the dialog on open, trap Tab / Shift+Tab within it while open, and restore focus to the opener on close.The canonical multi-platform
<Modal>(@pyreon/primitives) renders a native<dialog>on web, getting the trap + inert background from the platform.
No wiring required — open a modal and focus is managed correctly.
For a modal that interrupts with an urgent, response-requiring message (a confirmation, a destructive-action warning, an error), pass alert to ModalBase — it switches role="dialog" to role="alertdialog", which screen readers announce more assertively. General dialogs leave it off. Pair it with initialFocus={() => cancelRef()} so focus lands on the safe choice (Cancel) on open — an accidental Enter then can't confirm the destructive action. By default the dialog focuses its first focusable descendant.
Keyboard navigation — built into the primitives
Every interactive headless primitive in @pyreon/ui-primitives ships full keyboard support, so a keyboard user can operate it the moment you render it:
Calendar — Arrow keys move by day / week, Home/End to week edges, PageUp/PageDown by month (Shift = year), with WAI-ARIA grid roving focus.
Color picker — Arrow keys step the hue / saturation-brightness / alpha sliders, PageUp/Down for large steps, Home/End to min/max.
Tabs, Radio group, Combobox, Tree — arrow-key navigation between items per their ARIA roles.
File upload — the drop zone is a real
role="button"tab stop: Enter/Space (or a click) opens the native file picker, witharia-label,aria-disabled, andaria-busycarried automatically.
Pair the primitive's getDayProps / slider props / item props / dropZoneProps (which already carry onKeyDown + tabIndex + the right ARIA) onto your elements and the keyboard model comes with them.
Multi-platform accessibility
The canonical primitives in @pyreon/primitives carry a platform-neutral a11y vocabulary — write it once, each target emits its native model:
| Prop | Web | iOS (SwiftUI) | Android (Compose) |
|---|---|---|---|
accessibilityLabel="…" | aria-label | .accessibilityLabel(…) | .semantics { contentDescription = … } |
accessibilityHidden | aria-hidden="true" | .accessibilityHidden(true) | .clearAndSetSemantics { } |
accessibilityRole ("button" / "image" / "header") | role="button" / "img" / "heading" | .accessibilityAddTraits(.isButton / .isImage / .isHeader) | .semantics { role = Role.Button / Role.Image } / heading() |
import { Button } from '@pyreon/primitives'
<Button onPress={addToCart} accessibilityLabel="Add to cart">
<CartIcon />
</Button>Prefer these over raw aria-* (which is web-only) so the same component is accessible on every target.
Reduced motion
@pyreon/kinetic animations automatically respect the user's prefers-reduced-motion setting — when reduced motion is requested, transitions resolve instantly instead of animating. This covers every mode of the kinetic(tag) factory (.preset(), .collapse(), .stagger(), .group()). No configuration needed.
Correct ARIA by construction
Boolean ARIA attributes serialize correctly.
aria-checked={true}renders asaria-checked="true", not the presence-onlyaria-checked=""that assistive tech ignores — on both the client renderer and SSR.roleis typed. Theroleattribute isAriaRole, so you get autocomplete for the ~70 valid roles and a type error on a typo, while still accepting any string for forward-compatibility.
Linting for the rest
Some accessibility checks are project conventions rather than framework behaviour — @pyreon/lint ships them as opt-in best-practice rules (off by default, no noise):
pyreon/require-img-alt— every<img>needsaltpyreon/primitive-media-needs-label— the multi-platform analog: a canonical<Image>/<Icon>from@pyreon/primitivesneeds anaccessibilityLabel(oralt/aria-label), oraccessibilityHiddenif decorative — so a label-less media primitive is caught on web, iOS, and Android alike (auto-gated on the@pyreon/primitivesdependency)pyreon/img-requires-dimensions— intrinsicwidth/heightto avoid layout shiftpyreon/no-positive-tabindex,pyreon/no-autofocus,pyreon/no-redundant-role,pyreon/anchor-is-valid,pyreon/heading-order,pyreon/color-contrast, …
Enable the whole set with the best-practices preset, or per-rule in .pyreonlintrc.json:
{
"rules": {
"pyreon/require-img-alt": "error",
"pyreon/no-positive-tabindex": "error"
}
}Several are auto-fixable (pyreon-lint --fix), and each carries a prescriptive message so AI assistants and humans alike get the fix, not just the flag.
Summary
Reach for the framework primitives — @pyreon/form, @pyreon/elements / @pyreon/ui-primitives, @pyreon/primitives, @pyreon/kinetic — and accessibility comes with them. Add announce(), <LiveRegion>, and a <RouteAnnouncer> for the dynamic bits, turn on the opt-in lint rules to guard the conventions, and you have an accessible app by default.