Accessibility
A framework cannot make an application accessible, but it can refuse to leave the hard parts to each project. @zerolith/a11y ships the pieces that Angular CDK exposes and React Aria is built on, as plain functions over the DOM with no dependency beyond zerolith:
| Primitive | For | How it is used |
|---|---|---|
focusTrap() |
Modal dialogs | A ref on the dialog |
rovingTabindex() |
Tabs, menus, listboxes, radio groups, trees, and grids | A ref on the container |
createAnnouncer() |
Messages read aloud without moving focus | Call announce |
visuallyHidden |
Text that only assistive technology reads | Bound as a class |
focusOrigin() |
Focus rings for keyboard users only | Called once, then styled by an attribute |
mediaQuery(), forcedColors(), reducedMotion() |
The user's preferences | Read as signals |
The two ref primitives attach when the element is created and clean up when its template is removed, so there is nothing to remember to tear down. @zerolith/test checks the defects a test can see; the testing guide covers it.
Dialogs: focus trap
A modal dialog must take focus when it opens, keep Tab inside, and give focus back when it closes. focusTrap does all three from one ref:
import { html, render, signal, when } from "zerolith";
import { focusTrap } from "@zerolith/a11y";
const open = signal(false);
render(html`
<button @click=${() => open.set(true)}>Delete note</button>
${when(open, () => html`
<div role="dialog" aria-modal="true" aria-labelledby="confirm-title"
ref=${focusTrap()}>
<h2 id="confirm-title">Delete the note?</h2>
<button @click=${() => open.set(false)}>Cancel</button>
<button>Delete</button>
</div>`)}`, "#app");Focus that lands outside the dialog, by script or by a click, is pulled back in. When a dialog opens another, such as a confirmation, only the newest trap acts; closing it hands focus and control back to the dialog below.
| Option | Default | What it does |
|---|---|---|
initial |
The first focusable element inside, or the dialog itself | Where focus goes on open: a selector inside the dialog, an element, or false to leave focus alone |
restore |
true |
Returns focus to the element that had it before the dialog opened |
Composite widgets: roving tabindex
Tabs, menus, listboxes, radio groups, and trees are one Tab stop with arrow keys inside. rovingTabindex keeps exactly one item in the Tab order, moves between items with the arrow keys, Home, and End, starts on the item that is selected or checked, skips disabled items and the ones that are hidden or inert, such as the children of a collapsed tree item, and picks up items added later:
import { html, render } from "zerolith";
import { rovingTabindex } from "@zerolith/a11y";
render(html`
<div role="tablist" aria-label="Guides"
ref=${rovingTabindex({ orientation: "horizontal" })}>
<button role="tab" aria-selected="true">Signals</button>
<button role="tab" aria-selected="false">Templates</button>
<button role="tab" aria-selected="false">Routing</button>
</div>`, "#app");| Option | Default | What it does |
|---|---|---|
orientation |
"both" |
Which arrow keys move: "horizontal" for left and right, "vertical" for up and down |
loop |
true |
Wraps from the last item to the first and back |
items |
The composite ARIA roles, such as tab, option, and menuitem, and [data-roving] |
The selector of the items |
Announcements and hidden text
createAnnouncer adds two live regions to the page, hidden visually, which assistive technology reads without moving focus. announce(message) is polite and waits for the reader to finish; announce(message, "assertive") interrupts. Each announcement clears its region first, so the same message announced twice is read twice. visuallyHidden is the clip pattern for text that only assistive technology should get:
import { html, render } from "zerolith";
import { createAnnouncer, visuallyHidden } from "@zerolith/a11y";
const announcer = createAnnouncer();
render(html`
<a href="/report" target="_blank">
Report <span class=${visuallyHidden}>(opens in a new window)</span>
</a>`, "#app");
announcer.announce("Report saved");Focus origin
Focus rings belong on keyboard focus and are noise on pointer focus. focusOrigin watches how focus arrives and exposes it as a signal, origin, and as a data-focus-origin attribute on the root element, so one stylesheet rule does the rest:
[data-focus-origin="keyboard"] :focus { outline: 2px solid var(--accent); }| Origin | When |
|---|---|
"keyboard" |
A key was pressed before focus moved |
"pointer" |
A mouse or a pen was pressed |
"touch" |
A finger touched the screen |
"program" |
Anything else, such as element.focus() from script |
null |
Nothing in the document has focus |
Call it once, in main.ts; dispose() removes its listeners and the attribute.
Preferences
mediaQuery turns a media query into a signal that follows its matches. forcedColors and reducedMotion wrap the two every interface should follow: forced colors, such as Windows high contrast, where the system replaces author colors, and a request for less motion.
import { reducedMotion } from "@zerolith/a11y";
const calm = reducedMotion();
const duration = () => (calm.get() ? 0 : 200);Each is false wherever matchMedia does not exist, such as on the server, so a server-rendered page starts with motion and author colors and the browser corrects it once it hydrates.