Accessibility
Zero's first move is always the platform: a native <dialog>, <details>,
<select>, <button> or <input> brings its semantics, keyboard behavior and form
participation for free, and zero adds the anatomy and the wiring the platform leaves out.
Where the platform has no primitive — a listbox, a tree, a menu — the components implement
the WAI-ARIA Authoring Practices patterns. This page collects the rules that hold across the
library; every component page has the specifics.
Native elements first
| Component | Platform primitive | What it buys |
|---|---|---|
| Dialog, Drawer | <dialog> + showModal() | Top layer, focus trap, Escape, inert background, focus restore. No Portal. |
| Popover, Menu, Tooltip, Select, Combobox, Toast | the popover attribute | Top layer without z-index management; light dismiss where wanted (auto) or not (manual). |
| Collapsible, Accordion | <details> / <summary> | Disclosure with no JavaScript at all — it works before hydration and under resumable SSR. |
| Checkbox, Switch, RadioGroup, RatingGroup, NumberInput, Slider (scalar) | a real <input>, visually hidden or not | Form posting before hydration, native keyboard, platform labelling. |
| Input, Textarea, NativeSelect, FileUpload | the visible element is the control | name on the real element; nothing to keep in sync. |
| Table | <table> / <caption> / <thead> / <th> … | The elements are the semantics; no asChild anywhere. |
| Breadcrumbs, Timeline | <nav> + <ol>, <ul> + <li> | Order and list semantics AT already walks. |
| Kbd, Divider | <kbd>, role="separator" | The element is the meaning. |
A hidden-input part is a real, styleable part. Design systems hide it visually while
keeping it focusable, rather than display: none — the platform's Space, Tab and form
behavior stay attached to it.
Keyboard patterns
Tabs, Menu, Select, Combobox, RadioGroup, ToggleGroup, Steps, TreeView and the composed
Slider implement the APG pattern for their role: roving tabindex with one tab stop per
composite, arrow keys that follow orientation and mirror under RTL, Home / End, and
first-character typeahead where the pattern has it. Each component page has the key table.
Two decisions worth knowing:
- Selection and focus are separate acts where APG says so. ToggleGroup, Steps and
TreeView rove focus with the arrows without changing the selection; Enter / Space / click
select. Tabs selects on focus under
activationMode="automatic"and separates the two undermanual. - Pagination items are ordinary buttons. There is no APG pagination pattern; each page is its own meaningful tab stop.
Escape is universal
Escape dismisses every overlay from anywhere. A tooltip closes on Escape even when focus is
elsewhere (WCAG 2.1 SC 1.4.13). A modal <dialog> closes through its native cancel event;
a non-modal one, whose element fires no cancel, falls back to zero's dismiss layer. Menu
submenus close one level per Escape. dismissible={false} turns it off where a flow must
complete.
Labelling is presence-aware
An overlay references its Title and Description ids only while those parts are actually
rendered, so omitting a title never leaves a dangling aria-labelledby (which would suppress
the accessible-name fallback). Menu's root popup is labelled by its trigger; Menu.Group and
Select.Group are named by their GroupLabel while one is rendered. Drawer takes a label
prop as the aria-label fallback for the common navigation drawer with no visible heading.
Controls whose content is a glyph carry a default name and a label prop to override it:
Alert.Close and Toast.Close default to "Close"; Spinner defaults to "Loading";
Toggle and Swap take label for icon-only use; Status is aria-hidden without a
label and role="img" with one; Carousel.Root requires a label, because a nameless
region is an axe violation. RatingGroup names each item through
itemLabel={(index, count) => …} so the names localise.
Select.Trigger takes a label prop for a Select used outside a Field: role="combobox"
prohibits name-from-content, so the value text inside the trigger can never name it.
A default name gives way to an app aria-label: Spinner's "Loading", Alert.Close's
"Close", Breadcrumbs' "Breadcrumb", Pagination's "Pagination", and the default name of every
icon trigger — the Carousel triggers and dots, Diff.Handle, the NumberInput steppers,
FileUpload.ItemRemove, a Carousel slide's "n of m". A label prop, where the part has one,
still beats both. On Status and Countdown.Root an app aria-label names the part the way
label does: a named status dot is an img, a named countdown a timer.
Visually hidden, still named
A label or title can leave the screen and stay in the accessibility tree. Field.Label,
Input.Label, Textarea.Label, Dialog.Title and Drawer.Title take visuallyHidden, and
Switch.Root / Checkbox.Root take hideLabel for their label part. The label still names
its control through for, and the title still names its popup through aria-labelledby.
<Field.Root>
<Field.Label visuallyHidden>Search the docs</Field.Label>
<Input.Root>…</Input.Root>
</Field.Root>
The part renders data-visually-hidden, and @sigx/zero/css clips it in
@layer zero.structure, so no label recipe's display, margin or padding can put the box
back and a design system has nothing to write. The selector is not scope-qualified: an app
may stamp the attribute on any element of its own. For content that is not a part — an icon
button's text — use VisuallyHidden:
import { VisuallyHidden } from '@sigx/zero/visually-hidden';
<button type="button"><CloseIcon /><VisuallyHidden>Close</VisuallyHidden></button>
<VisuallyHidden asChild>{(p) => <h2 {...p}>Navigation</h2>}</VisuallyHidden>
It renders a <span> (or, under asChild, hands the attribute to your element) and is also
exported from the @sigx/zero root. It is deliberately not a scope and has no anatomy, since
there is nothing in it for a design system to style. Prefer the part's own visuallyHidden
when the thing to hide is a part: the part keeps its wiring, where wrapping its text would
leave an empty styled box behind.
For design-system authors, visuallyHidden is a presentation request, not a
flag: the anatomy declares which
parts offer it (PartSpec.visuallyHidden, carried into the manifest), it mints no selector,
and the contrast and state-legibility tooling never cross it. expectAnatomy fails
data-visually-hidden on a part that does not declare it.
Field and Switch share one accessible name — give it once. Inside a Field, Field.Label
(for the input) and Switch.Root (a <label> wrapping it) are both labels of the same
input, and an accessible name concatenates every label a control has. Name it in exactly one
place: Field.Label with a Switch.Root that has no children of its own, or the Switch's own
text and no Field.Label. When a row elsewhere carries the visible text, keep the Switch's
text as the name with hideLabel. The same holds for Checkbox.
<Field.Root>
<Field.Label>Dark mode</Field.Label>
<Switch.Root /> {/* name: "Dark mode" */}
</Field.Root>
<Switch.Root hideLabel>Airplane mode</Switch.Root>
Attribute pass-through
sigx forwards no rest props, so a part renders only what it declares. Every part an app
writes declares the everyday attributes (WithHtmlAttrs) and forwards aria-*, the app's
own data-*, id, title and role to the element it renders, or into its asChild bag:
<Button.Root aria-label="Close" data-testid="close" onClick={close}>×</Button.Root>
<Table.Row data-row-id={row.id}><Table.Cell colSpan={5}>No results</Table.Cell></Table.Row>
<Card.Root role="region" aria-labelledby="report-title">…</Card.Root>
<Alert.Close aria-label="Dismiss" />
<Tabs.List aria-label="Settings">…</Tabs.List>
<Checkbox.Root aria-label="Accept terms" data-testid="terms" />
Where the attributes land:
- On the part's own element, for nearly every part. A root that renders no element
(
Dialog.Root,Drawer.Root, …) takes none; its trigger and popup carry their own. - Split, where the part wraps the element assistive technology reads.
Table.Rootputsaria-*androleon the<table>and the rest on its scroll wrapper.Checkbox.Root,Switch.RootandRadioGroup.Itemputaria-*on their input andid,titleanddata-*on the row. - Nowhere, for parts zero renders on its own — hidden inputs, Pagination's page buttons, a switch thumb, a backdrop. There is no component to take an attribute.
The part's own attributes win where both set one, with three refinements:
- A name the part always sets is refused by the type, not silently dropped.
roleis refused where it is the component's semantics:Divider,Spinner,Status,Countdown.Root,Alert.Root, the Progress and RadialProgress roots, the tablist, tabs and tab panels, the tree and its items, thegrouproots of Steps and ToggleGroup, the Carousel region and slides, the switch, checkbox, radio, toggle, slider-thumb and spinbutton parts, andField.Error'salert.idis refused where another part points at it: every Label, the Field description and error, the disclosure panels,Tabs.Tab/Tabs.Panel, and the Field control of NumberInput, RatingGroup, Slider and FileUpload. - A default name gives way to an app
aria-label— see labelling. - Wired references join. An app
aria-labelledby/aria-describedbyis joined to the one the part wires rather than replacing it: a progress bar's Label, a tab panel's tab, a tree's or radio group's label, a rating control's, and a control's Field description. State ARIA stays the component's —aria-busy,aria-current,aria-valuenow,aria-expanded,aria-selected,aria-checked,aria-pressedand the like.
A data-* name the anatomy contract owns — data-scope, data-part, data-state,
data-orientation, data-placement, any flag, data-color / data-size / data-variant,
and the data-mod-* / data-l-* prefixes — is a compile error where TypeScript can see it
and throws at runtime either way: a design system selects on those, so an app writing
data-state="open" from outside would make the skin say something the component never did.
An id, title or role takes a string, and an aria-* boolean renders as its "true" /
"false" token. To build a forwarding part of your own, intersect WithHtmlAttrs into its
props (Omit the names it owns) and spread htmlAttrs(props) first — see
Building your own component.
Field integration
Inside a Field.Root, a control adopts the field's control
id — so Field.Label names it — and its disabled, invalid and required flags, and
announces the field's description and error through aria-describedby. Input, Textarea,
NumberInput, Checkbox, Switch, RadioGroup, Select, NativeSelect, Combobox, RatingGroup,
ToggleGroup and FileUpload all consume the context — and a control with no size of its own
renders the Field's, so <Field.Root size="xs"> is a compact field, control included. This is the wiring a raw <input> never gets, and the
main reason the text-field components exist.
Live regions
Alert.Root is role="alert": a live region announces changes, so a server-rendered
alert is silent at load and one inserted later is announced. Toast roots are role="status"
(or alert) inside a role="region" viewport. Skeleton sets aria-busy while loading.
A transcript of Chat rows that needs log semantics is a role="log" container the app
writes around them — a single message row carries no ARIA of its own.
Reduced motion and forced colors
Every declared --duration-* token collapses to 0.01ms under
prefers-reduced-motion: reduce — so a recipe that references the token honours the
preference automatically, and a hardcoded 0.2s opts out. Looping animations (Skeleton,
Spinner, indeterminate Progress) are the exception: they must stop rather than speed up,
so their recipes use a literal duration and animation: none under the reduced-motion
condition. State indicators drawn as background geometry carry a glyph fallback under
forced-colors and print (the --print-ink property is the ink
it draws with). See Recipes.
What stays the app's job
- The accessible name of a
Button.Rootwith only an icon inside — passaria-label, or put the text in aVisuallyHidden. - Announcing a long-running operation. A
loading button says
aria-busyand keeps focus; announce progress with a live region or a labelledSpinnerwhen a label change alone won't be heard. - The
<nav>around aNavbar's link set — the bar itself is a<header>banner landmark because it holds non-navigation content too. - Writing a
Table.Caption. It is the table's accessible name.
Verification
The shipped design systems are audited with axe-core across every playground page
(serious and critical WCAG A/AA failures are hard errors), a contrast audit measures every
state combination of every skin and theme against a 3:1 floor for text and indicator paint,
and the expectAnatomy assertion holds every rendered part to the
contract.
