Guide

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:

GuaranteeHow it is enforced
Every theme’s focus ring, control boundary, and primary colour clear their contrast barMachine-checked for every built-in theme in tests/theme-focus-contrast.test.ts — see Theme contrast.
Nothing focusable is left without a visible indicatorA 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 namesButtons 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 itModal 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 setting13 @media (prefers-reduced-motion: reduce) blocks in the stylesheet, one of which disables every animate preset and hover utility outright.
Windows High Contrast stays legibleTwo @media (forced-colors: active) blocks switch borders to CanvasText and focus rings to Highlight.
Common ARIA defects are catchable in CIaxe(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:

ComponentAccessibility behaviour
Modalrole="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 · SheetSame 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.
Toastrole="alert" (assertive) for the danger tone and role="status" (polite) for every other tone, so only real problems interrupt a screen reader.
NavLinkMarks the matching route with aria-current="page" alongside data-active="true".
FormControlAssociates 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 · CalendarViewA real role="grid" with role="row" / role="columnheader" rows, a single roving tab stop, and disabled days skipped rather than stalled on.
Tabstablist / 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:

KeyAction
Tab / Shift+TabMove 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 / SpaceActivate the focused control. Because controls are real <button> / <a> / <input> elements, this is native behaviour — there is no key handler to get wrong.
EscDismiss 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 keysMove within a composite widget. 24 components implement this — the full list is below, because “where applicable” is not something you can build against.
Home / EndJump 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:

PatternComponentsKeys
Tab strips & option stripsTabs, SegmentedControl, ToggleGroup, VariantSelector move the selection, Home/End jump to the ends
Menus & listbox popupsDropdownMenu, ContextMenu, Combobox, MultiSelect, NotificationBell, CommandPalette move the active option, Home/End to the ends, Enter commits
Inline autocompleteMentionInput only — no Home/End, because those belong to the text field
TreesTree, JsonTree move, collapse / expand, Home/End to the ends
Date gridsCalendar, CalendarView a day, a week, Home/End the row (CalendarView adds PageUp/PageDown for months)
MediaCarousel, Lightbox step between slides; Carousel adds Home/End
Value steppersRating, QuantityStepper change the value, Home/End the extremes (QuantityStepper adds PageUp/PageDown)
Splitters & keyboard dragResizablePanels, Sortable, OnGesture (and on the latter two) resize or reorder without a pointer; Esc cancels a Sortable drag
Text entryPinInput, 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:

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:

InvariantBarWhy that token carries it
colorFocusRing against the theme’s own colorSurface≥ 3:1The 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:1The 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:1Over 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 familyPainted asBarlight values
colorSuccess · Warning · Danger · InfoFills, bars, dots, chart marks — shapes, never text3:1#10b981 · #f59e0b · #ef4444 · #06b6d4
colorSuccessText · WarningText · DangerText · InfoTextThe same status hue rendered as text on a surface4.5:1#047857 · #a35a00 · #d92d20 · #0e7490
colorOnSuccess · OnWarning · OnDanger · OnInfoInk painted on top of the status fill4.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

Avoid — a styled div has no role or keyboard support
OnClick(
  Box([Text("Delete")], { padding: "sm" }),
  { onClick: () => remove() }
)
Prefer — a real button is focusable & named
Button("Delete", {
  variant: "danger",
  icon: "trash",
  onClick: () => remove()
})

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.

PropShapeEffect
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.
rolestringTrimmed, lower-cased, and set only if it is on the allow-list below. Anything else is dropped silently.
id / anchorstringSets the element id (must match /^[A-Za-z][\w-]*$/) — the target for SkipLink and aria-labelledby.
tooltipstringSets the native title attribute. Not a substitute for a label.
hiddenbooleanSets 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.

CategoryAccepted values
Landmarks & structurebanner, complementary, contentinfo, form, main, navigation, region, search, article, group, list, listitem, separator, heading, figure, note, definition, term
Live regions & statusstatus, alert, log, timer, marquee, progressbar, meter
Common widgetsbutton, link, img, toolbar, tooltip, dialog, tab, tabpanel, tablist, menuitem, option, checkbox, radio, switch
Explicitly neutralnone, 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.

SignatureWhat 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.

Live — tab into the page: the skip link appears first, and the status text is announced politely
$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.

PropTypeDefaultBehaviour
childNodeRequired, positional. The subtree to confine. children is an accepted alias.
activebooleantrueBind 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.
restoreFocusbooleantrueReturn 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.
onEscapecallablenoneCalled on Esc inside the trap, and the keystroke is consumed so an outer surface does not also close. Without it, Esc does nothing.
autoFocusboolean | stringtruetrue 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.

Live — open the panel, then press Tab repeatedly (focus cycles) and Escape (onEscape closes it)
$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:

ruleFlags
img-altAn <img> with no alt attribute at all.
svg-nameA 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-nameA button or link with no accessible name. aria-labelledby counts only when the referenced element exists.
labelAn input/select/textarea with no label, aria-label, resolvable aria-labelledby, or wrapping <label>.
duplicate-idThe same id twice in the audited subtree.
tabindexA 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

  1. Every interactive element is reachable and operable by keyboard, and composite widgets are one tab stop with arrow keys inside.
  2. Focus is visible everywhere and never trapped except intentionally — and every intentional trap has an Esc route out.
  3. All controls have accessible names; icon-only controls carry aria: { label } or a VisuallyHidden child.
  4. Status and error messages use the correct Toast tone or LiveRegion politeness — assertive only for interruptions.
  5. Ids come from $id, not literals, so nothing collides.
  6. A custom theme retints colorBorderControl, colorFocusRing, and every *Text / colorOn* partner — not just the base hues.
  7. Meaning isn’t conveyed by colour alone.
  8. axe(screen.shadowRoot) returns an empty array in CI.

Next