Screen readers
Every state a sighted user sees has a programmatic equivalent: aria-expanded, aria-selected, aria-checked, aria-current, aria-pressed. This page is about the trickier half — names, descriptions and errors, which have to cross a shadow boundary.
Naming a control
Section titled “Naming a control”An accessible name comes from one of:
aria-label— a string, always reliable.- A slotted label — text inside the component, when the component knows how to name itself from it.
hui-field— the recommended route for a form control, because it also wires the description and the error.
A control with no accessible name is a defect, not a permitted configuration. aria-labelledby also works, but only on the host for native or light-DOM controls; see the boundary note below.
<hui-field> <span data-label>Project name</span> <hui-input data-control name="project" value="Home-UI"></hui-input> <span data-description>Shown in the sidebar.</span></hui-field>The shadow boundary
Section titled “The shadow boundary”This is the rule that trips people up:
An IDREF attribute such as
aria-labelledbyoraria-describedbycannot resolve a light-DOMidfrom inside a shadow root, in any browser.
So a component whose control lives in its shadow root cannot be named by pointing at an element outside it. Home-UI therefore:
- forwards the string attributes —
aria-label,aria-description,aria-invalid,aria-required— from the host onto the internal control; - and
hui-fieldsupplies the effective text asaria-labelandaria-description, rather than as an IDREF.
aria-description support is narrower than aria-describedby, which is the one place a host may reasonably prefer a native control. The tooltip is the exception: the trigger and the tooltip content are light-DOM siblings, so an aria-describedby between them resolves normally.
Wiring validation errors
Section titled “Wiring validation errors”hui-field reads its children by data hook — data-label, data-control, data-description, data-error — and wires the control:
- the label becomes the control’s accessible name;
- description and error text are joined and set as the description;
aria-invalidis set when the host marks the field invalid.
<hui-field> <span data-label>Project name</span> <hui-input data-control name="project" aria-invalid="true"></hui-input> <span data-error>Project name is required.</span></hui-field>This is how server-side Go validation reaches a screen reader after an HTMX swap: the server re-renders the same markup with aria-invalid and the error text, and the component does the rest.
Live regions
Section titled “Live regions”hui-toast announces through a live region owned by the toast container: aria-live="polite" by default, assertive for a destructive variant. A host does not add aria-live itself.
Focus and disabled state
Section titled “Focus and disabled state”- After an HTMX swap, no component steals focus. Restoring focus after a swap is the host’s decision.
disabledcontrols are removed from the tab order, as native controls are.aria-disabledcontrols remain focusable where the role expects it — an unavailable menu item keeps its place and its name, and is announced but cannot be chosen.- Focus is always visible; see Focus.
How it is checked
Section titled “How it is checked”packages/ui/tests/accessibility/ runs axe with wcag2a, wcag2aa, wcag21a and wcag21aa over the gallery, the compatibility harness and each open overlay, in light and dark, with zero violations. Two axe limitations are recorded rather than worked around:
- A modal dialog’s backdrop dims the page, so a page-wide scan measures dimmed content; the dialog scan is scoped to the panel.
- axe cannot resolve a shadow-root background for slotted text, so the popover and tooltip scans disable
color-contrast;scripts/measure-contrast.mjsenforces the same ratios directly from the shipped sheet.
Automated tooling does not prove conformance. The accessibility tree and at least one screen reader are part of the manual pass.