Accessibility.
LLM-generated UI is high-stakes for accessibility — the markup is produced for the user, not hand-audited. Aktion bakes accessible semantics into its components so generated output is usable by keyboard and screen reader by default. This page documents what’s built in, what the test suite actually enforces, and what you still own.
What the runtime guarantees
Aktion is not certified against a conformance level, and this page does not claim one — WCAG conformance is a property of a finished page, and the library controls only part of it.
What it does do is ship accessible behaviour by default, and pin the measurable parts to tests in this repository:
| Guarantee | How it is enforced |
|---|---|
| Every theme’s focus ring, control boundary, and primary colour clear their contrast bar | Machine-checked for every built-in theme in tests/theme-focus-contrast.test.ts — see Theme contrast. |
| Nothing focusable is left without a visible indicator | A zero-specificity global rule paints a 2px outline in the focus token on every button, a[href], summary, positive tabindex, and widget role at :focus-visible. |
| Components render real interactive elements with accessible names | Buttons are <button>, links are <a href>, fields are <input>/<select>/<textarea>, so Enter / Space activation and name computation are the browser’s, not a handler’s. |
| Dialogs are labelled, trap focus, and restore it | Modal renders role="dialog" + aria-modal="true" + aria-labelledby, moves focus in, locks background scroll (ref-counted for nesting), and restores focus on close. |
| Motion respects the OS setting | 13 @media (prefers-reduced-motion: reduce) blocks in the stylesheet, one of which disables every animate preset and hover utility outright. |
| Windows High Contrast stays legible | Two @media (forced-colors: active) blocks switch borders to CanvasText and focus rings to Highlight. |
| Common ARIA defects are catchable in CI | axe(node) from aktion-runtime/test — see Auditing. |
The criteria the source and tests name as targets are 1.4.11
(non-text contrast, the 3:1 bar), 2.4.11 (focus
appearance), and 2.4.3 (focus order — what
FocusTrap’s restoreFocus exists for). They
are design targets, not an audit result.
What you get for free vs. what you own
The library handles: roles, accessible names, focus
order, focus trapping in Modal, live-region announcements,
reduced-motion, forced-colors, and RTL. You still own:
custom component semantics, custom-theme contrast, and meaningful
alt text / labels on the content you generate. The
axe() audit and the
pre-ship checklist catch the rest.
ARIA built into components
These behaviours come from the component itself, so a generated program gets them without asking. Everything here was read out of the component source, not inferred from the class names:
| Component | Accessibility behaviour |
|---|---|
Modal | role="dialog", aria-modal="true", and aria-labelledby pointing at its title. Renders in the browser’s top layer, traps Tab, moves focus in on open and restores it on close, locks background scrolling, and closes on Esc (unless closable: false). |
Drawer · Sheet | Same overlay contract: labelled panel, backdrop and Esc dismissal, onClose on every route out. Sheet takes label when there is no visible title, so it is never an unnamed dialog. |
Toast | role="alert" (assertive) for the danger tone and role="status" (polite) for every other tone, so only real problems interrupt a screen reader. |
NavLink | Marks the matching route with aria-current="page" alongside data-active="true". |
FormControl | Associates its label with the nested control automatically (override with for), and adds the validation slot — error marks the control invalid, required adds the required attribute. |
Calendar · CalendarView | A real role="grid" with role="row" / role="columnheader" rows, a single roving tab stop, and disabled days skipped rather than stalled on. |
Tabs | tablist / tab / tabpanel wiring plus the full arrow‑key contract below. |
Keyboard map
Aktion adds no global shortcuts of its own. Everything below is either the browser’s native behaviour on a real element, or a documented component contract:
| Key | Action |
|---|---|
| Tab / Shift+Tab | Move focus forward / backward through interactive elements. Inside a Modal, Drawer, Sheet, or an explicit FocusTrap, the cycle wraps instead of escaping to the page behind. |
| Enter / Space | Activate the focused control. Because controls are real <button> / <a> / <input> elements, this is native behaviour — there is no key handler to get wrong. |
| Esc | Dismiss the topmost open overlay: Modal, Drawer, Sheet, Popover, HoverCard, Tooltip, DropdownMenu, ContextMenu, Combobox, MultiSelect, MentionInput, NotificationBell, CommandPalette, Lightbox, SpeedDial. It also cancels an InlineEdit and an in-flight keyboard drag in Draggable / Sortable. The innermost surface consumes the keystroke, so one press never closes two things. |
| Arrow keys | Move within a composite widget. 24 components implement this — the full list is below, because “where applicable” is not something you can build against. |
| Home / End | Jump to the first / last item, in every widget marked in that list. |
Esc on a bare FocusTrap does nothing by default
FocusTrap only acts on Esc when you give
it onEscape. The overlay components wire their own,
which is why Modal closes and a hand-rolled panel does not.
See FocusTrap.
Which components implement arrow-key navigation
Grouped by the interaction pattern, so you can tell what a key will do before you press it:
| Pattern | Components | Keys |
|---|---|---|
| Tab strips & option strips | Tabs, SegmentedControl, ToggleGroup, VariantSelector | ←→↑↓ move the selection, Home/End jump to the ends |
| Menus & listbox popups | DropdownMenu, ContextMenu, Combobox, MultiSelect, NotificationBell, CommandPalette | ↑↓ move the active option, Home/End to the ends, Enter commits |
| Inline autocomplete | MentionInput | ↑↓ only — no Home/End, because those belong to the text field |
| Trees | Tree, JsonTree | ↑↓ move, ←→ collapse / expand, Home/End to the ends |
| Date grids | Calendar, CalendarView | ←→ a day, ↑↓ a week, Home/End the row (CalendarView adds PageUp/PageDown for months) |
| Media | Carousel, Lightbox | ←→ step between slides; Carousel adds Home/End |
| Value steppers | Rating, QuantityStepper | ←→↑↓ change the value, Home/End the extremes (QuantityStepper adds PageUp/PageDown) |
| Splitters & keyboard drag | ResizablePanels, Sortable, OnGesture | ←→ (and ↑↓ on the latter two) resize or reorder without a pointer; Esc cancels a Sortable drag |
| Text entry | PinInput, RichTextEditor | ←→ move between PinInput slots (Backspace steps back too); caret motion in the editor |
Every widget in the first four rows uses a roving tab stop: the group is one Tab stop and the arrows move inside it, which is what keeps a 30-item menu from adding 30 stops to the page.
Screen readers & streaming
Because UI streams in token-by-token, content can change rapidly. To keep the experience calm for assistive tech:
- The morph reconciler patches the DOM in place rather than replacing it, so focus, selection, and a screen reader’s reading position survive re-renders.
- Status changes use polite/assertive live regions via
Toastroles — reserve the assertivedangertone for errors that must interrupt. - Prefer revealing a region once its content has settled (e.g. an
Async/Lazyfallback) over announcing every intermediate token. - Announce the outcome, not the progress: one
LiveRegionthat flips to “Saved” beats a region bound to a value that changes on every keystroke.
Theme contrast
The built-in themes are not eyeballed. Three contrast invariants are
asserted for every one of them in
tests/theme-focus-contrast.test.ts, so a retint that breaks one
fails the build rather than shipping:
| Invariant | Bar | Why that token carries it |
|---|---|---|
colorFocusRing against the theme’s own colorSurface | ≥ 3:1 | The focus indicator is the control’s border-color switching to the full-strength focus token. The 22 %-alpha glow that accompanies it measures only ~1.2–1.4:1 and cannot carry the indicator on its own. |
colorBorderControl against colorSurface | ≥ 3:1 | The boundary of a control the user has to locate: Input, Select, Textarea, Checkbox, Radio, the Switch track, and the picker triggers. |
colorPrimary against colorSurface | ≥ 4.5:1 | Over a hundred rules paint color: var(--rui-color-primary) as body text, and the same token is the fill behind the primary button’s white label — so it must clear the text bar in both directions. |
That third invariant is why light.colorPrimary is
#4f46e5
(6.29:1 on white) and no longer the indigo-500 #6366f1, which
measured 4.47:1 — fine as large text, short as the small text the
library actually paints with it. colorPrimaryHover moved with
it, to #4338ca.
colorBorder and colorBorderControl are two different jobs
Do not collapse them into one value.
colorBorder is deliberately faint
(#e2e8f0
on light, and comparably faint in every other theme) because the
same token draws dividers and table row separators, where a 3:1 hairline
reads as heavy ruling. colorBorderControl
(#767f8c)
exists precisely so form controls can clear 3:1 while decorative
hairlines stay quiet. Retint both when you author a
theme.
One more split worth knowing: colorAccent has two jobs —
a fill paired with colorAccentText, and link text. Themes whose
accent already clears 4.5:1 as text inherit a derived
colorLink; soft, mui, heroui
and signal ship an explicit one because theirs do not —
HeroUI's #006fee is 3.8:1 as text on its own dark card, so
heroui-dark paints links in #66aaf9 instead while the
button FILL stays HeroUI blue.
The three-way status colour split
A status hue cannot do three jobs at three different contrast bars, so each one is three tokens. This is the part of the theme system most likely to be missed when you write a custom palette:
| Token family | Painted as | Bar | light values |
|---|---|---|---|
colorSuccess · Warning · Danger · Info | Fills, bars, dots, chart marks — shapes, never text | 3:1 | #10b981 · #f59e0b · #ef4444 · #06b6d4 |
colorSuccessText · WarningText · DangerText · InfoText | The same status hue rendered as text on a surface | 4.5:1 | #047857 · #a35a00 · #d92d20 · #0e7490 |
colorOnSuccess · OnWarning · OnDanger · OnInfo | Ink painted on top of the status fill | 4.5:1 against that fill | #04291e · #451a03 · #2c0606 · #0c2b3a |
#10b981 measures 2.54:1 on white, so around forty-five rules
— Badge, Tag, Pill,
Callout, the StatCard value, a danger
MenuItem, field errors — take the *Text token
instead of the fill.
Symmetrically, everywhere that used a literal #fff on a status
fill now takes colorOn*: a completed Step tick, a
Toast icon disc, a danger Button label, a count
badge.
The rule for theme authors
Retint a status hue → retint its *Text and
colorOn* partners. Change
colorSuccess alone and your success text and your
on-fill ink still come from the default palette, so they stop matching
the brand and may stop clearing their bar. Two themes already break the
“white ink is fine” assumption in the other direction:
shadcn sets colorOnDanger: "#ffffff" and
mui sets colorOnSuccess: "#ffffff", because
those fills are dark enough for it.
The rest of the rule set is short: body text at 4.5:1, large text and UI
affordances at 3:1, and never meaning from colour alone — pair
tone with an icon or a label, as the components already do.
One caveat on the invariants: they compare opaque hex values, so a theme
whose surface or ink is an rgba(…) — Material's
rgba(0,0,0,0.87) body text, for one — is skipped by the test
and reviewed by hand.
Themes has the full token reference.
Keeping custom components accessible
OnClick(
Box([Text("Delete")], { padding: "sm" }),
{ onClick: () => remove() }
)
Button("Delete", {
variant: "danger",
icon: "trash",
onClick: () => remove()
})
- Build from real interactive elements (
button,a,input) rather than click-handlerdivs. The global focus-visible rule only nets real controls and widget roles — a baredivgets nothing. - Give every control an accessible name (visible label,
aria: { label: "…" }, oraria: { labelledby: "…" }). - Use
$id(prefix?)to generate unique ids for label/description associations instead of hard-coding them — hard-coded ids collide the moment the component renders twice, andaxe()flags it asduplicate-id. - Manage focus for anything that opens or closes: wrap it in
FocusTrapwhile open rather than reimplementing the cycle. - An icon-only control needs a label somewhere — either
aria: { label }on the button or aVisuallyHiddenchild.
The universal role and aria channels
Every component accepts a small set of universal props on top of its own, and two of them are the escape valve for ARIA you cannot fix from the outside. Reach for them when a component’s default semantics are wrong for your context — not as a first resort.
| Prop | Shape | Effect |
|---|---|---|
aria | { label, labelledby, describedby, expanded, … } | Each key is lower-cased and prefixed with aria- when it is not already, then set as an attribute. null values are skipped, and a key must be 2–33 lowercase letters or hyphens. |
role | string | Trimmed, lower-cased, and set only if it is on the allow-list below. Anything else is dropped silently. |
id / anchor | string | Sets the element id (must match /^[A-Za-z][\w-]*$/) — the target for SkipLink and aria-labelledby. |
tooltip | string | Sets the native title attribute. Not a substitute for a label. |
hidden | boolean | Sets the hidden attribute, removing the node from the accessibility tree as well as the layout. |
The role allow-list is closed on purpose: an unrecognised role
is ignored by assistive tech anyway, and a plausible-but-wrong one
(button on a container, a landmark with no owned children) is
worse than the defect it was meant to work around.
| Category | Accepted values |
|---|---|
| Landmarks & structure | banner, complementary, contentinfo, form, main, navigation, region, search, article, group, list, listitem, separator, heading, figure, note, definition, term |
| Live regions & status | status, alert, log, timer, marquee, progressbar, meter |
| Common widgets | button, link, img, toolbar, tooltip, dialog, tab, tabpanel, tablist, menuitem, option, checkbox, radio, switch |
| Explicitly neutral | none, presentation |
$app(Column([
Column([
Input("q", { placeholder: "Search invoices", aria: { label: "Search invoices" } }),
Button("Search", { variant: "primary" })
], { gap: "sm", role: "search" }), // landmark on a plain container
Row([
IconButton("trash", { aria: { label: "Delete selected" } }), // name an icon-only control
IconButton("download", { aria: { label: "Export selected" } })
], { gap: "xs", role: "toolbar" }), // one group, not two loose buttons
Box([Text("3 of 12 selected")], { role: "status" }) // announced whenever the text changes
], { gap: "md" }))
Roles that need matching owned children or ARIA state
(gridcell, row, treeitem…) are
deliberately absent, because a bare role override cannot supply them. Use
the component that already builds the whole pattern instead.
none and presentation go the other way: they take a
purely decorative wrapper out of the accessibility tree entirely.
Accessibility primitives
Four components cover the most common screen‑reader and focus‑management needs. They render the right ARIA and behaviour so you don’t hand‑roll it.
| Signature | What it does |
|---|---|
VisuallyHidden(children) | Renders content that is invisible on screen but present in the accessibility tree (the sr-only pattern) — icon-button labels, form hints, context a sighted user gets from layout. children is Node[] and takes the positional slot; child is an accepted alias. |
SkipLink(to, { label }) | A “skip to main content” link, hidden until focused. to is positional and required — the target element id, with or without the #. label defaults to "Skip to content". |
LiveRegion(text, { politeness, visible }) | An aria-live region with aria-atomic="true". politeness: "polite" (default) renders role="status"; "assertive" renders role="alert". Visually hidden unless visible: true. |
FocusTrap(child, { active, restoreFocus, onEscape, autoFocus }) | Confines Tab to its subtree. child is positional and required; see the full prop table below. |
SkipLink takes its target positionally
SkipLink("#main", { label }), not
SkipLink({ to, label }). Because the runtime is
lenient about argument shapes, the object-only form parses and renders an
empty link instead of erroring — which is exactly why it is worth
calling out.
$status = "Nothing saved yet"
$app(Column([
SkipLink("#main", { label: "Skip to content" }),
Column([Text("Main content starts here.")], { id: "main" }),
Button("Save", { variant: "primary", onClick: () => { $status = "Saved just now" } }),
LiveRegion(`Status: ${$status}`, { politeness: "polite" }), // announced, not shown
VisuallyHidden(Text("Press Escape to close")) // shown to AT only
], { gap: "md" }))
Give the SkipLink target a real id through the
universal id channel. If that id is missing at click time the
link lands on the nearest content landmark (main,
[role="main"], the render root, or the first h1)
instead of sending the hash router to a bogus route.
$status = "Nothing saved yet"
$app(Column([
SkipLink("#main", { label: "Skip to content" }),
Button("Save", { variant: "primary", onClick: () => { $status = "Saved just now" } }),
Column([Text($status)], { id: "main" }),
LiveRegion(`Status: ${$status}`, { politeness: "polite" }),
VisuallyHidden(Text("This sentence is read by screen readers but hidden visually."))
], { gap: "md" }))
FocusTrap — confine keyboard focus
Wrap any subtree in FocusTrap while it is open and
Tab / Shift+Tab cycle within it
instead of escaping to the page behind. It is the primitive for
hand‑rolled dialogs, drawers, and command palettes.
Modal, Drawer, and Sheet already trap
focus, so reach for it only when you build the overlay yourself.
| Prop | Type | Default | Behaviour |
|---|---|---|---|
child | Node | — | Required, positional. The subtree to confine. children is an accepted alias. |
active | boolean | true | Bind it to the open state so the trap engages only while the surface is visible. Flipping it to false releases focus and runs the restore. |
restoreFocus | boolean | true | Return focus to whatever opened the trap when it closes — including when it is unmounted in the same tick. Set false only when you move focus somewhere else yourself. |
onEscape | callable | none | Called on Esc inside the trap, and the keystroke is consumed so an outer surface does not also close. Without it, Esc does nothing. |
autoFocus | boolean | string | true | true focuses the first focusable control in document order on open. A CSS selector focuses that element instead ("[data-cancel]" for a destructive dialog). false opts out entirely. |
Initial focus fires on the closed→open transition only, so typing inside a trapped form never yanks the caret back to the first field.
$open = false
function closePanel() { $open = false }
$app(Column([
Button("Open panel", { variant: "primary", onClick: () => { $open = true } }),
$open ? FocusTrap(
Card([
CardHeader("Edit profile", { subtitle: "Tab cycles inside. Escape closes." }),
Input("name", { placeholder: "Name", aria: { label: "Name" } }),
Input("email", { placeholder: "Email", aria: { label: "Email" } }),
Row([
Button("Cancel", { onClick: closePanel, data: { cancel: "true" } }),
Button("Save", { variant: "primary", onClick: closePanel })
], { gap: "sm" })
]),
{ active: $open, onEscape: closePanel, autoFocus: "[data-cancel]" }
) : Text("Panel closed.", { tone: "muted" })
], { gap: "md" }))
autoFocus: "[data-cancel]" lands on Cancel rather than the
first field — the right default for a dialog whose primary action is
destructive. The selector is resolved inside the trap, so
data: { cancel: "true" } on the button is all the wiring it
needs.
RTL & logical layout
Set dir on the host <aktion-app> element to flip
the whole tree for right‑to‑left languages. The runtime reflects it
onto the render root, so direction and CSS logical properties
mirror text, flex order, and logical spacing automatically.
<aktion-app dir="rtl"></aktion-app>
<!-- "rtl" | "ltr" | "auto" -->
Because spacing and layout primitives use logical properties, most programs
need no changes — the same tree lays out correctly in both directions.
The direction key inside $theme({…}) is
advisory metadata only; the attribute is what flips layout.
Reduced motion & forced colors
Motion is suppressed automatically under
prefers-reduced-motion: reduce. A global rule disables every
animate preset and hover utility; twelve more scoped blocks stop
or slow individual animations; and motion components (Reveal,
Parallax, Confetti, Backdrop,
TypingIndicator, Sheet…) guard themselves
with matchMedia.
You can branch on the same flag from a program:
anim = $util.media.prefersReducedMotion ? "none" : "fade-up"
Card([Text("Respectful by default")], { animate: anim })
Looping indicators are slowed rather than frozen — a
Spinner or Skeleton that stopped entirely would
read as a hung page — while decorative animation
(Confetti, blobs, pulsing dots) is switched off outright.
Two @media (forced-colors: active) blocks keep components legible
in Windows High Contrast mode. Borders on buttons, cards, inputs, badges,
tags, selects, textareas, tables, and modals switch to
CanvasText, and focus rings switch to Highlight.
The second block reverts the gradient-title effect the soft theme
uses to plain CanvasText, so those headings do not vanish.
Auditing with axe()
The testing utilities ship an
axe(node) audit that needs no dependency. It returns an array
of { rule, message, element } violations — empty means
clean — so it drops straight into an assertion:
rule | Flags |
|---|---|
img-alt | An <img> with no alt attribute at all. |
svg-name | A standalone <svg> with no aria-label, no <title>, and no aria-hidden="true". An SVG inside a button or link is judged by its control’s name instead. |
button-name · link-name | A button or link with no accessible name. aria-labelledby counts only when the referenced element exists. |
label | An input/select/textarea with no label, aria-label, resolvable aria-labelledby, or wrapping <label>. |
duplicate-id | The same id twice in the audited subtree. |
tabindex | A positive tabindex, which disrupts document tab order. |
import { describe, it, expect } from "vitest"
import { render, axe, flush } from "aktion-runtime/test"
describe("dashboard a11y", () => {
it("has no axe violations", async () => {
const screen = render(program)
await flush() // let the first render commit
const issues = axe(screen.shadowRoot) // NOT screen.container — see below
expect(issues).toEqual([]) // failure prints rule + message + element
})
})
Audit screen.shadowRoot, not screen.container
axe() walks the subtree with
querySelectorAll, which does not cross a shadow
boundary. screen.container is the
<aktion-app> host and the program renders inside
its shadow root, so axe(screen.container) always returns an
empty array — a test that passes because it looked at nothing. Pass
screen.shadowRoot, and await flush() first so
there is something there to audit.
within(node) from the same entry point scopes queries to one
subtree, which pairs well with auditing a single Card or dialog
rather than the whole app. See Testing for
the surrounding harness and TypeScript
for the A11yViolation type.
Pre-ship checklist
- Every interactive element is reachable and operable by keyboard, and composite widgets are one tab stop with arrow keys inside.
- Focus is visible everywhere and never trapped except intentionally — and every intentional trap has an Esc route out.
- All controls have accessible names; icon-only controls carry
aria: { label }or aVisuallyHiddenchild. - Status and error messages use the correct
Toasttone orLiveRegionpoliteness — assertive only for interruptions. - Ids come from
$id, not literals, so nothing collides. - A custom theme retints
colorBorderControl,colorFocusRing, and every*Text/colorOn*partner — not just the base hues. - Meaning isn’t conveyed by colour alone.
axe(screen.shadowRoot)returns an empty array in CI.