Skip to content
Home Theme Gallery

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.

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>

This is the rule that trips people up:

An IDREF attribute such as aria-labelledby or aria-describedby cannot resolve a light-DOM id from 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-field supplies the effective text as aria-label and aria-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.

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-invalid is 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.

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.

  • After an HTMX swap, no component steals focus. Restoring focus after a swap is the host’s decision.
  • disabled controls are removed from the tab order, as native controls are. aria-disabled controls 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.

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.mjs enforces 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.