Accessibility
What Hypen derives for free, the accessibility applicators, cross-node relationships, and how to check your app with hypen check
Accessibility
Hypen derives accessibility semantics in the engine — role, accessible name,
state, and relationships are computed once from your template and carried on
every patch as a single typed block. Each renderer translates that block to
its native accessibility API: ARIA attributes on DOM, a shadow tree on Canvas,
accessibilityLabel/traits on iOS, Compose semantics on Android, and AccessKit
on desktop. You describe intent (.label, .hidden, .role) — never
platform tokens like aria-*.
Whatever the engine cannot derive is flagged loudly at dev time: hypen check
prints findings with file:line:col, and the LSP shows the same findings as
editor squiggles with quick fixes.
What you get for free
Roles and names are derived automatically where they are structurally certain from the component type — no annotation needed:
| Component | Derived role | Derived name |
|---|---|---|
Button("Save") | button | "Save" from text content |
Link("Docs") | link | "Docs" from text content |
Paragraph | paragraph | — |
Heading("Title", level: 2) | heading + level | visible text |
Image(src: "…", alt: "…") | img | the alt text |
Input / TextArea | textbox | needs a label (see below) |
Checkbox(label: "Accept terms") | checkbox | the visible label |
Switch(label: "Dark mode") | switch | the visible label |
Select | listbox | needs a label |
Slider | slider | needs a label |
ProgressBar | progressbar | — |
Spinner | status + busy | — |
Tabs / Tab / TabPanel | tablist / tab / tabpanel | Tab names from content |
Option | option | from content |
Combobox | combobox | needs a label |
Names derived from nested text content work too — Button { Text("Save") }
gets the name "Save". Templated content (Button("@{state.action}"))
resolves reactively: the name updates whenever the bound state changes.
Containers (Column, Row, Card, …), List, and Icon deliberately derive
nothing: the engine cannot tell a decorative icon from a meaningful one, or a
List used as a semantic list from one used as a toolbar. A wrong role is
worse than none — you opt in with .role(...) when you mean it. When you do
opt a container into .role("list"), its direct role-less children are
automatically given listitem, so the list reads as a real list to assistive
tech without per-child annotation.
The accessibility applicators
| Applicator | Meaning | Notes |
|---|---|---|
.label("…") | Explicit accessible name | Overrides any derived name; accepts @{state.x} templates (reactive) |
.description("…") | Supplementary description | aria-description / accessibilityHint / stateDescription |
.hidden() | Decorative — remove from the accessibility tree | Clears all other semantics |
.role("…") / .landmark("…") | Opt-in role for containers | Known tokens: button, link, list, navigation/nav, main, region, search, banner/header, contentinfo/footer, complementary/aside, dialog, tablist, tab, tabpanel, option, combobox, listbox. Typos are flagged, not silently ignored |
level: N (or .level(N)) | Heading level 1–6 | Required on Heading — the checker flags a missing level |
.expanded(bool) | Disclosure state | Static or bound (.expanded(@state.open)) — bound values update live |
.pressed(bool) | Toggle-button state | Static or bound |
.selected(bool) | Selection state (tabs, options) | Static or bound |
.current("page") | Current item in a set | Tokens like "page", "step", "true" |
.invalid(bool) | Form-control validity | Static or bound (.invalid(@state.hasError)) → aria-invalid |
.dir("rtl") | Base text direction | "ltr", "rtl", or "auto" — static only; unknown tokens flagged |
.liveRegion("polite") | Announce content changes | "polite" or "assertive" → aria-live; put it on the element whose text changes reactively. Unknown tokens are currently ignored silently — spell them exactly |
.bind(@state.x) | Two-way binding | On Checkbox/Switch also emits reactive checked state for free |
Two more building blocks:
VisuallyHidden { Text("…") }— content invisible on screen but read by screen readers (the standard "sr-only" pattern)..aria(key, value)— a raw, DOM-only escape hatch (.aria("haspopup", "menu")→aria-haspopup="menu"). It bypasses the portable semantics block and never reaches Canvas, iOS, or Android. Prefer the intent applicators whenever one exists.
// Icon-only button: the derived name is missing, so label it.
Button { Icon("trash") }.label("Delete")
// Decorative image next to text that already says it all.
Icon("checkmark").hidden()
// Navigation landmark on a plain container.
Column { … }.landmark("navigation")
// Reactive disclosure state.
Button("Menu").expanded(@state.menuOpen).controls("menu-panel")
Column { … }.id("menu-panel")Labelling form controls
Form controls (Input, TextArea, Select, Slider, Combobox) get their
accessible name from a label, not their content — a placeholder is a hint,
not a label. Hypen gives you three ways, and the checker flags any control
that has none of them:
1. An explicit .label(...):
Input(placeholder: "you@example.com").bind(@state.email).label("Email")2. A .labelledby(...) reference. Naming by reference to a visible
element counts as a label — the checker accepts it whenever the referenced
id resolves (a dangling reference is flagged separately by
dangling-reference):
Text("Search settings").id("search-label")
Input(placeholder: "Search").labelledby("search-label")3. Auto-association with a preceding Text. The ubiquitous
label-then-field layout is wired for you: when a static Text immediately
precedes an unlabeled control under a parent with an explicit .id(...), the
engine mints the id/labelledby pair automatically, and the Text's content
becomes the control's name on every platform:
Column {
Text("Email")
Input(placeholder: "you@example.com").bind(@state.email)
Text("Bio")
TextArea { }.bind(@state.bio)
}.id("profile-form")Auto-association is deliberately conservative — it only fires for the
immediately preceding sibling, only when that Text is static and carries no
other semantic job, and only inside a stable id namespace (the parent's
.id(...), or the Text's own .id(...)). An explicit .label(...) or
.labelledby(...) on the control always wins.
The text must also look like a label: at most 40 characters, at most 5
words, and no trailing sentence punctuation (., !, ? — a trailing :
is label-like and still wires). Instructional prose that happens to precede a
control ("All fields are required.") fails the shape test, so it is never
taken as the control's accessible name; the control simply stays unlabeled
and form-control-missing-label fires, pointing you at an explicit
.label(...). Declining to wire is always the safe direction — a prose text
that genuinely is your label just needs .label (or a shorter label), while
hint text belongs elsewhere (or wired with .describedby).
Checkbox and Switch self-label: their visible label: argument is their
accessible name, so Checkbox(label: "Accept terms").bind(@state.ok) needs
nothing more.
Cross-node relationships
Some semantics describe a relationship between two nodes — a button and the panel it controls, an input and its hint text. These are expressed by reference to a stable id:
| Applicator | Meaning |
|---|---|
.id("panel-1") | Declares this node's stable id — the anchor references resolve against |
.controls("panel-1") | This node controls that node (disclosure, tab → panel) |
.describedby("hint-1") | That node describes this one (input → hint/error text) |
.labelledby("tab-1") | That node labels this one (panel → tab) |
.owns("popup-1") | That node is logically my child even though it renders elsewhere (portaled popups) |
.activedescendant(@state.focusedId) | The currently-active descendant — usually bound, updating live as the user arrows through options |
All of these accept bound or templated values. Inside a ForEach, mint
per-item ids from your own item data — the same data your module state uses:
Column {
ForEach(items: @state.options) {
Text("@{item.label}").role("option").id("opt-@{item.id}")
}
}.role("listbox").activedescendant(@state.focusedId)Arrowing through options in your module (state.focusedId = "opt-3") moves
the accessibility pointer with no further wiring.
References are validated: a static .controls("missing-panel") whose target
no element declares is flagged by the dangling-reference rule (only when the
document declares at least one .id(...) — ids living outside Hypen are left
alone).
Platform reach: id relationships apply as aria-* on DOM and as real
AccessKit relationships on desktop. iOS and Android have no id-reference
accessibility API, so these are dropped there — where the relationship carries
the name (e.g. labelledby), pair it with content the platform can speak,
or use .label(...).
Tabs: an auto-wired composite
A Tabs block with an explicit id gets its full reference graph minted at
compile time — tab.id, tab.controls → panel.id, panel.labelledby → tab.id, positionally paired:
Tabs {
Tab("Overview").selected(@state.overviewSelected).onClick(@actions.showOverview)
Tab("Settings").selected(@state.settingsSelected).onClick(@actions.showSettings)
TabPanel { … }
TabPanel { … }
}.id("settings")This mints settings-tab-0 ↔ settings-panel-0 and so on.
When tabs and panels are mixed under one container (as above), ARIA forbids
the container itself from being the tablist (role="tablist" may only own
tabs). The engine restructures the shape for you: the outer container becomes
a plain group (it keeps your .id(...), styling, and everything else), and a
synthetic inner element carrying role="tablist" is inserted holding only
the tab children, positioned where the first tab was. Panels stay direct
children of the outer container. Layout follows the restructure: the outer
container defaults to column so the tab strip sits above the panels (a
flexDirection you set yourself always wins), and a .gap(...) on the
container is mirrored onto the tab strip — tabs stay spaced exactly as you
wrote them, and the same gap separates the strip from the panels. When the
children are all tabs (panels rendered elsewhere), no restructuring happens —
the container itself is the tablist, and its layout is untouched.
The contract:
- The tablist must have an
.id(...)— it is the namespace ids are minted from. No id, no wiring. - Direct children with tab/tabpanel roles are paired positionally, and the
counts must match. A nonzero mismatch disables wiring rather than
manufacturing dangling references, and the checker explains the skip with
tablist-wiring-skipped— balance the pairs, or move every panel outside and hand-wire with.controls/.labelledby. - Any value you set yourself (
.id,.controls,.labelledby) wins over the minted one..role("tab")opt-ins on other element types participate. - Tabs generated by a
ForEachare not auto-wired (the pairs aren't statically visible) — mint ids from item data as shown above. - Selection stays yours, via
.selected.
Keyboard behaviour you get on the web
The DOM renderer implements operability client-side, keyed off the semantic role — no configuration:
- Activation — actionable non-native elements (e.g. a clickable
Card) become focusable and activate on Enter/Space, announced as buttons. NativeButton/Linkhosts already work. - Dialogs —
.role("dialog")gets the full modal contract: focus moves into the dialog when it appears (first focusable element, else the dialog itself), Tab and Shift+Tab cycle within it and wrap at the ends, Escape dispatches the dialog'sonCloseaction when one is declared (noonClose, no Escape behaviour — closing stays your module's decision), and focus returns to the triggering element when the dialog is removed. - Tablists — the WAI-ARIA roving-tabindex contract: Arrow keys move between tabs with wrap-around, Home/End jump to first/last, and the whole tablist is a single Tab stop.
- Listboxes — non-native listboxes (
.role("listbox")withoptionchildren) get the same roving behaviour plus first-character typeahead. - Route navigation — when the router swaps routes, focus moves to the new
route's first heading, else its
mainlandmark, else the subtree root — so screen readers announce the new page. Returning to a cached route restores the focus you left there. Apps with their own focus management can opt out withnew DOMRenderer(root, { routeFocus: "off" }).
Checking your app: hypen check
hypen check # whole project
hypen check src/components/profile.hypen # one file
hypen check src/components src/shared # specific directoriesRuns the engine's accessibility conformance pass. With no arguments it scans
every .hypen file in your project (the configured components directory
from hypen.json); with positional arguments it checks exactly those files
and directories. Findings print with exact source locations:
src/components/profile.hypen:14:5: a11y[missing-accessible-name] Button — interactive Button has no accessible name — add visible text or a label
Found 1 accessibility issue across 1 file.Exit codes (CI-safe — "couldn't check" is never reported as clean):
| Code | Meaning |
|---|---|
0 | Ran, no issues found |
1 | Ran, found issues |
2 | Could not (fully) check: engine binding unavailable, a file failed to read or parse, or a given path does not exist |
If the engine WASM predates this checker, missing rules are reported as a
prominent drift warning (the check still runs with the rules the binding
does implement) — rebuild with bun run build:wasm.
The rules — each fires only on a gap the engine cannot derive its way out of, so a clean template stays silent:
| Rule | Fires when | Fix |
|---|---|---|
missing-accessible-name | An interactive control (button/link) has no derivable name — e.g. icon-only | Add visible text or .label("…") |
image-missing-alt | An Image has no alt | Add alt: "…" (or .hidden() if decorative) |
form-control-missing-label | A form control has no label | Add .label("…"), a resolving .labelledby(...), or use the auto-association pattern |
heading-missing-level | A Heading has no level | Add level: N (1–6) |
nested-interactive | A button/link is nested inside another | Restructure — one interactive control per node |
unknown-role-token | .role(...)/.landmark(...) used an unrecognised token (typo) | Check the spelling |
unknown-dir-token | .dir(...) was not ltr/rtl/auto | Use a valid token |
unknown-live-token | .liveRegion(...) was not polite/assertive | Use a valid token |
dangling-reference | .controls/.describedby/.labelledby/.owns/static .activedescendant targets an id no element declares | Add .id("…") to the target |
duplicate-id | Two elements statically declare the same .id(...) in one document (mutually-exclusive When/Router branches are exempt) | Rename one id |
tablist-wiring-skipped | A Tabs with an .id(...) has a nonzero, unequal number of tab and panel children, so auto-wiring was skipped | Balance the pairs, or portal every panel out and hand-wire |
non-portable-aria | .aria(...) was used — it only reaches the DOM renderer (informational) | Prefer a portable applicator when one exists |
unknown-ignore-rule | A // hypen-a11y-ignore directive names a rule id that doesn't exist, so that entry suppresses nothing | Check the spelling against this table |
Notes on the reference rules: ids minted from item data inside a ForEach
(.id("opt-@{item.id}")) exist only at runtime, so a document containing any
dynamic id cannot have its references statically validated —
dangling-reference goes silent for that whole document rather than flag
correct code. Likewise a document declaring no .id(...) at all is assumed
to reference ids living outside Hypen.
Suppressing findings
Both mechanisms keep suppression visible: suppressed findings never fail the
check, but the report always ends with N finding(s) suppressed.
Inline, per element — a // hypen-a11y-ignore comment, either trailing
any line of the element's expression or alone on the line directly above it
(no blank line in between). Bare, it suppresses every rule on that element;
with a comma-separated rule list, only those rules:
// hypen-a11y-ignore missing-accessible-name
Button { Icon("logo") }
Input(placeholder: "q") // hypen-a11y-ignore form-control-missing-labelAn element's expression runs from its name through the end of its applicator chain, so for a multiline element the natural spot — trailing the last applicator — works:
Button {
Icon("trash")
}
.padding(16) // hypen-a11y-ignore missing-accessible-nameThe directive never reaches past the expression: a comment on the line
after the last applicator applies to nothing, and the comment-above form
breaks on a blank line, so a directive orphaned by later edits can't silently
latch onto whatever element drifts beneath it. A rule id the engine doesn't
implement is itself flagged (unknown-ignore-rule) rather than silently
matching nothing — a typo'd directive tells you it's broken.
Project-wide, per rule — hypen.json:
{ "a11y": { "ignoreRules": ["non-portable-aria"] } }During development
hypen dev --a11y runs the same conformance pass over your .hypen sources
on the initial build and on every rebuild, printing findings to the dev
server console in the same <file>:<line>:<col>: a11y[rule] format. To make
it the project default (no flag needed), set it in hypen.json:
{ "a11y": { "dev": true } }It is off by default and purely informational: findings never stop or fail
the dev server, both suppression mechanisms above apply exactly as in
hypen check, and a clean pass prints nothing.
In the editor
The Hypen LSP runs the same conformance pass on every edit and publishes
findings as inline squiggles (source hypen-a11y, the rule id as the
diagnostic code). Severity is conservative: nested-interactive is an error,
missing names/labels/alt and unknown tokens are warnings, a missing heading
level is informational.
Quick fixes are available from the lightbulb menu:
missing-accessible-name/form-control-missing-label→ appends.label("")after the element's expressionimage-missing-alt→ insertsalt: ""into the argument listheading-missing-level→ insertslevel: 1
Platform support
One semantics block travels to every renderer, but each platform's accessibility API can express different parts of it. What each renderer consumes — and what it deliberately drops:
| Semantics | DOM | Canvas (shadow tree) | iOS (SwiftUI) | Android (Compose) | Desktop (AccessKit) |
|---|---|---|---|---|---|
| Role | role attribute (overrides native tags when intentional) | role on generic hosts | traits (button/link/header/image/search, dialog→modal) | Role.* (Button/Tab/Checkbox/Switch/Image/DropdownList) | full role map incl. composites |
| Derived name (visible text) | left to the browser | applied as aria-label (pixels are invisible to AT) | left to visible text | left to visible text | AccessKit label |
Explicit .label / image alt | aria-label | aria-label | accessibilityLabel | contentDescription | label |
.description | aria-description | aria-description | accessibilityHint | folded into stateDescription | description |
.hidden | aria-hidden | omitted from the shadow tree | .accessibilityHidden | clearAndSetSemantics {} | — |
| Heading level | <hN> / aria-level | shadow <hN> | .accessibilityHeading(.h1–.h6) | heading() (no level) | Heading role (no level) |
Self-state (expanded/pressed/selected/current) | aria-* | aria-* | accessibilityValue | stateDescription | — |
checked (from .bind) | aria-checked | aria-checked | accessibilityValue | stateDescription | — |
.invalid | aria-invalid | — | — | — | — |
.liveRegion | aria-live | aria-live | — | — | — |
.dir | dir attribute | — | — | — | — |
Id references (.id/.controls/.describedby/.labelledby/.owns/.activedescendant) | ARIA id references | — | dropped (string-hint API only) | dropped | real AccessKit NodeId relations |
| Keyboard (activation, dialog trap, roving tabindex, typeahead, route focus) | ✓ | — | native platform behaviour | native platform behaviour | AccessKit actions (Click) |
Reactive updates (setSemantics re-emits for bound names and state) reach
all five renderers — a dash above means the field has no faithful
target on that platform, never that updates go stale. Derived names staying
with visible text is deliberate and uniform: an explicit .label overrides,
visible content speaks for itself.
See also
- Inputs & Forms — form components and two-way binding
- Styling — the general applicator model
- CLI — all
hypencommands - LSP — editor setup