HypenHypen
Guide

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:

ComponentDerived roleDerived name
Button("Save")button"Save" from text content
Link("Docs")link"Docs" from text content
Paragraphparagraph
Heading("Title", level: 2)heading + levelvisible text
Image(src: "…", alt: "…")imgthe alt text
Input / TextAreatextboxneeds a label (see below)
Checkbox(label: "Accept terms")checkboxthe visible label
Switch(label: "Dark mode")switchthe visible label
Selectlistboxneeds a label
Slidersliderneeds a label
ProgressBarprogressbar
Spinnerstatus + busy
Tabs / Tab / TabPaneltablist / tab / tabpanelTab names from content
Optionoptionfrom content
Comboboxcomboboxneeds 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

ApplicatorMeaningNotes
.label("…")Explicit accessible nameOverrides any derived name; accepts @{state.x} templates (reactive)
.description("…")Supplementary descriptionaria-description / accessibilityHint / stateDescription
.hidden()Decorative — remove from the accessibility treeClears all other semantics
.role("…") / .landmark("…")Opt-in role for containersKnown 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–6Required on Heading — the checker flags a missing level
.expanded(bool)Disclosure stateStatic or bound (.expanded(@state.open)) — bound values update live
.pressed(bool)Toggle-button stateStatic or bound
.selected(bool)Selection state (tabs, options)Static or bound
.current("page")Current item in a setTokens like "page", "step", "true"
.invalid(bool)Form-control validityStatic 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 bindingOn 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:

ApplicatorMeaning
.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-0settings-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 ForEach are 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. Native Button/Link hosts 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's onClose action when one is declared (no onClose, 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") with option children) 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 main landmark, 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 with new 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 directories

Runs 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):

CodeMeaning
0Ran, no issues found
1Ran, found issues
2Could 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:

RuleFires whenFix
missing-accessible-nameAn interactive control (button/link) has no derivable name — e.g. icon-onlyAdd visible text or .label("…")
image-missing-altAn Image has no altAdd alt: "…" (or .hidden() if decorative)
form-control-missing-labelA form control has no labelAdd .label("…"), a resolving .labelledby(...), or use the auto-association pattern
heading-missing-levelA Heading has no levelAdd level: N (1–6)
nested-interactiveA button/link is nested inside anotherRestructure — 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/autoUse a valid token
unknown-live-token.liveRegion(...) was not polite/assertiveUse a valid token
dangling-reference.controls/.describedby/.labelledby/.owns/static .activedescendant targets an id no element declaresAdd .id("…") to the target
duplicate-idTwo elements statically declare the same .id(...) in one document (mutually-exclusive When/Router branches are exempt)Rename one id
tablist-wiring-skippedA Tabs with an .id(...) has a nonzero, unequal number of tab and panel children, so auto-wiring was skippedBalance 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-ruleA // hypen-a11y-ignore directive names a rule id that doesn't exist, so that entry suppresses nothingCheck 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-label

An 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-name

The 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 rulehypen.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 expression
  • image-missing-alt → inserts alt: "" into the argument list
  • heading-missing-level → inserts level: 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:

SemanticsDOMCanvas (shadow tree)iOS (SwiftUI)Android (Compose)Desktop (AccessKit)
Rolerole attribute (overrides native tags when intentional)role on generic hoststraits (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 browserapplied as aria-label (pixels are invisible to AT)left to visible textleft to visible textAccessKit label
Explicit .label / image altaria-labelaria-labelaccessibilityLabelcontentDescriptionlabel
.descriptionaria-descriptionaria-descriptionaccessibilityHintfolded into stateDescriptiondescription
.hiddenaria-hiddenomitted from the shadow tree.accessibilityHiddenclearAndSetSemantics {}
Heading level<hN> / aria-levelshadow <hN>.accessibilityHeading(.h1–.h6)heading() (no level)Heading role (no level)
Self-state (expanded/pressed/selected/current)aria-*aria-*accessibilityValuestateDescription
checked (from .bind)aria-checkedaria-checkedaccessibilityValuestateDescription
.invalidaria-invalid
.liveRegionaria-livearia-live
.dirdir attribute
Id references (.id/.controls/.describedby/.labelledby/.owns/.activedescendant)ARIA id referencesdropped (string-hint API only)droppedreal AccessKit NodeId relations
Keyboard (activation, dialog trap, roving tabindex, typeahead, route focus)native platform behaviournative platform behaviourAccessKit 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 hypen commands
  • LSP — editor setup