Skip to content

08 - Accessibility

Accessibility is a contract, not a feature. Keyboard-unreachable = broken. Screen-reader-invisible = broken. Low-contrast = broken. No exceptions.

This chapter is the minimum floor. Every component in @na/ui already meets it by design; your job is to not break it when composing.

Audit reference: WCAG 2.1 AA. We aim higher where it’s cheap.


Keyboard contract

Every view must be fully operable from the keyboard alone. This is especially important in admin (power users live there).

Global shortcuts (every page)

KeyAction
⌘K / Ctrl+KOpen command palette
?Open keyboard shortcut overlay
EscClose the topmost dialog/popover/menu
Cmd+BToggle sidebar collapse
/Focus the primary search input on the page
Tab / Shift+TabMove focus forward / backward
EnterActivate focused button or link
SpaceActivate focused checkbox / switch / button

List navigation

KeyAction
j / Move to next item
k / Move to previous item
EnterOpen / activate focused item
EscClear selection or leave list
xToggle selection (when row-selection is enabled)
Shift+ClickRange select
Cmd+ASelect all

Command palette entries and keyboard overlays should be discoverable — the ? overlay is not optional; every app implements it.

Form editing

KeyAction
TabNext field
Shift+TabPrevious field
EnterSubmit form (on a text field) / activate button
EscCancel current edit (click-to-edit, inline)
Cmd+EnterSubmit from within a <Textarea>
Cmd+SSave current form (if there’s an unambiguous Save action)

Dialog / popover

KeyAction
EscClose (if closable)
TabCycle focus within the dialog (trap active)
EnterActivate the primary button

Shortcut registry — collision prevention

The tables above reserve keys globally. Any new scoped shortcut must be added to this registry before landing — first-come-first-served, documented conflicts only. The rule: if two contexts need the same key, the more-specific context wins (an InboxList’s e for archive beats a global “e for edit” that doesn’t exist today).

ScopeKeyActionOwner file
Global⌘KCommand paletteAppLayout.tsx (when it ships)
Global?Shortcut overlayAppLayout.tsx (when it ships)
Global/Focus primary searchEach page-level search component
Global⌘BToggle sidebarSidebar.tsx
List / InboxjNext item<InboxList>, <DataTable> when row-selection active
List / InboxkPrevious itemsame
InboxeArchive current<InboxList>
Inbox⌘↵Mark read + next<InboxList>
List w/ selectionxToggle row selection<DataTable> when selectable
List w/ selection⌘ASelect all<DataTable> when selectable
Inline editCommit<InlineEditField>
Inline editEscCancel<InlineEditField>
Form (textarea)⌘↵Submit containing form<ActionButton type="submit"> ancestor
Text-heavy page⌘SSave (if unambiguous)Settings forms, workflow editor

Reserved for future use (do not claim without adding to this table):

  • g (chord prefix for “go to X”) — e.g. g a = Agents, g t = Tools. Common Linear/Superhuman pattern; if we add this, the list-nav j/k and the chord g don’t conflict.
  • n — “new” (currently claimed by nothing). Candidate for “new agent” from agents list.
  • / combined with search scope — e.g. / agent <name> to jump to an agent. Out of scope until command palette ships.

Mobile / touch: no keyboard shortcuts apply. Don’t render ? hints on touch devices.


Focus management

Focus on mount

  • Dialog opens → focus the primary interactive element (first input, or the primary button if there are no inputs).
  • Sheet opens → same.
  • Page load with unpopulated form → focus first empty required field.
  • Page load with populated form → do NOT autofocus (users are navigating, not editing yet).
  • After mutation success on a dialog → close dialog; focus returns to the trigger automatically (Radix handles this).

Focus on close

  • Dialog / Sheet / Popover close → focus returns to the trigger element. Radix does this; don’t override.

Focus on wizard step transitions

Wizards built per 06-flows.md § Multi-step wizard need explicit focus management at step boundaries. Don’t leave focus on the Next button after a click — the user’s eyes move to the new content; their focus should follow.

  • On step advance (forward): focus the first interactive element of the new step. For a form step, that’s the first empty input. For Step 4 (Review), focus the “Create” submit button (so Enter works immediately).
  • On step retreat (back): focus the last interactive element the user touched on the prior step (or the first input if no prior touch — e.g., they’re returning fresh).
  • On stepper segment click (only allowed for completed steps): same as step retreat — focus the first input on the destination step.
  • On submit success: focus moves with route navigation. Don’t try to focus anything in the wizard after navigate() fires — the wizard component is unmounting.
  • On submit failure: if the failure is a validation error mapped to a specific field (e.g., HTTP 409 name conflict), jump back to the relevant step AND focus the offending field, not the submit button.

Wizard live regions

Step changes need a aria-live="polite" announcement so screen-reader users know they advanced. The <Stepper> primitive announces “Step N of M:

Focus trap

Radix Dialog, Sheet, Popover, DropdownMenu all implement focus traps correctly. Don’t build modals without them — you’ll miss the trap.

<FocusLayout> is NOT a modal — it does not render role="dialog", does not trap focus, and the user can Tab out into the Sidebar at any time. That’s intentional: a focus page is a focused page, not an overlay. Don’t add a focus trap; don’t add role="dialog". The wizard is responsible for guarding dirty state on navigation (see § Esc behavior below + 06-flows.md § Exit-path close behavior).

Esc behavior on FocusLayout pages

The Esc key on a wizard page must produce visible feedback every time. Per 06-flows.md § Exit-path close behavior:

  • Clean (not dirty): Esc navigates immediately to closeTo.
  • Dirty: Esc programmatically clicks the ✕ button ref to open its <ConfirmPopover>. Same prompt as clicking ✕ directly. Never return early from the Esc handler with no UI — that’s a silent no-op and looks broken.

Don’t bind Esc to a separate “show confirm dialog” path — reuse the visual popover anchored to ✕. One confirm path for all exit triggers.

Focus ring

Every interactive element has a visible focus ring. Our Button component uses:

focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
  • Don’t remove focus rings (outline: none without replacement).
  • Don’t shrink them below 3px.
  • Don’t hide them behind custom hover styles.

ARIA

Rule 0: semantic HTML first

Use <button> for buttons. <a> for links. <input> for inputs. <nav> for nav. <main> for main content. Only reach for ARIA when semantic HTML can’t express the relationship.

Landmark roles

Every page has exactly:

  • <nav aria-label="App"> — the sidebar (L2 navigation).
  • <main>PageContent.
  • <header> — page bar (ListPageBar / DetailPageBar).

Our shell components already apply these. Don’t add conflicting landmarks.

Required ARIA per component

Most are handled by Radix + our wrappers. The ones you might need to set manually:

Component / elementAttribute
Icon-only buttonaria-label (or wrap in <Tooltip> — we do)
Icon in text (decorative)aria-hidden="true"
Loading statearia-busy="true" on the live region
Live updates (toast, SSE)aria-live="polite" (our ActionButton does this)
Progressrole="progressbar" + aria-valuenow + aria-valuemax
Expanded state (accordion)aria-expanded
Required form fieldaria-required="true"
Field with erroraria-invalid="true" + aria-describedby="<errorId>"
Field with help textaria-describedby="<helpId>"
Selected item (list)aria-selected
Active tabaria-selected="true"
Modalrole="dialog" + aria-modal="true" (Radix default)

aria-label copy

  • Buttons with visible labels: don’t add aria-label (duplicates).
  • Icon-only buttons: aria-label describes the action (“Edit row”, “Delete row”).
  • Decorative icons adjacent to text: aria-hidden="true".

Screen reader announcements

Live regions

For announcements that should interrupt or follow-up:

  • Polite (aria-live="polite"): mutation success/pending/error (our ActionButton has this on the inner Button). Toast success messages.
  • Assertive (aria-live="assertive"): critical errors that block work (“Connection lost”), security alerts.

After a mutation

The user needs to hear what happened. Our ActionButton uses aria-live="polite" so the state text (“Saving…”, “Saved”, error) is announced. Don’t toast the same message — it’ll announce twice.

After route changes

React Router doesn’t announce route changes by default. For the admin app, we rely on users reading the <h1> / page bar title. If you add a route-announce component, scope it to one — don’t have two announcers fighting.


Contrast

Minimums (WCAG 2.1 AA)

ContentMinimum ratio
Normal text4.5 : 1
Large text (≥ 18px bold or 24px regular)3 : 1
UI component boundaries3 : 1
Graphical objects (icons, chart lines)3 : 1

Our tokens (text-foreground / bg-background, text-muted-foreground / bg-background) meet these in both light and dark mode. If you override with raw colors, you’ll likely break them.

Things that often break contrast

  • Custom semi-transparent overlays on images.
  • Placeholder text (we render placeholder at ~50% opacity — still ≥ 3:1).
  • Disabled states (we use opacity-50 which can drop below 4.5:1 for normal text — that’s acceptable for disabled).
  • Colored icons on colored backgrounds (red icon on red bg).

Don’t convey meaning with color alone

A red dot + “Offline” label works; a red dot alone doesn’t. Always pair color with shape, icon, or text.


Motion + reduced motion

Respect prefers-reduced-motion.

@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}

Already applied globally in our CSS. Component-level animations (animate-spin, etc.) are exempt because they’re functional (loading indicators). If you add a decorative animation, make sure it respects prefers-reduced-motion.


Touch targets (mobile + tablet)

Minimum 40 × 40 px hit target for any interactive element on touch devices.

  • Our Button size="icon" is 40px — good.
  • size="icon-xs" is 24px visual — we pad the hit area so the actual tappable region is ≥ 40px.

If you build a custom interactive element smaller than 40px visually, use padding to extend the hit area.


Forms

Label every input

Every input has a <Label> associated via htmlFor. Our <FormField> wraps this automatically.

Bad: placeholder as label. Good: visible label + optional placeholder.

Error announcements

When a field fails validation:

  • The field gets aria-invalid="true".
  • The error message has an id; the field has aria-describedby="<errorId>".
  • The error message is visible, not just announced.

Our <FormMessage> (inside <FormField>) handles all of this.

Submit button state

When submitting:

  • The button becomes disabled.
  • The button has aria-busy="true" (our ActionButton sets this).
  • The button text changes to "Saving…" (announced via aria-live).

Inline validation

Validate on blur for format checks; validate on submit for uniqueness. Don’t validate while typing except for password strength and character counts — constantly-changing errors are anxiety-inducing.


Tables

Headers

Every column header is a <TableHead> (renders <th scope="col">).

Row selection

Row checkboxes:

  • Header checkbox: toggle all visible rows.
  • Row checkbox: aria-label="Select <row identifier>" (e.g., “Select contact John Doe”).
  • Select-all checkbox: aria-label="Select all <n> <items>".

Sortable columns

Sortable header buttons have aria-sort="ascending" | "descending" | "none".


Keyboard-navigable nav

Every nav item is focusable with Tab. Arrow keys between items is nice-to-have but not required (sidebars are short).

At the top of the DOM tree, a “Skip to main content” link that’s visible only on focus:

<a
href="#main"
className="sr-only focus:not-sr-only ..."
>
Skip to main content
</a>

Our shell layout already includes this.

Current page indicator

Active L2 sidebar item has aria-current="page". Active L3 tab has aria-selected="true". Screen readers announce these.


Dialogs

Radix Dialog handles:

  • Focus trap while open.
  • Esc to close.
  • Focus restore to trigger on close.
  • role="dialog", aria-modal="true", aria-labelledby to the title.

Your job: provide a visible <DialogTitle> and <DialogDescription>. The title is required for screen readers.

If you visually hide the title (rare), wrap it in <VisuallyHidden> — don’t omit it.


Toasts

Sonner toasts are accessible by default. They announce via aria-live="polite" (or assertive for errors).

Don’t stack more than 3 toasts — users can’t keep up.


Images and icons

Decorative

<ChevronDown aria-hidden="true" />
<img src="..." alt="" /> // empty alt for decorative images

Informative

<img src="avatar.jpg" alt="Jane Doe" />
<span className="sr-only">Online</span> // invisible text for status indicators

Functional

An icon that IS the button needs aria-label on the button (or a tooltip — our Button wraps tooltip into an accessible name automatically).


Color-blindness

  • Don’t use red/green alone to indicate success/failure — always pair with icons (CheckCircle / AlertCircle).
  • Status colors in charts: use distinct hues and distinct shapes (solid line vs dashed) or labels.
  • Test with a simulator (browser dev tools “Emulate vision deficiencies”).

Testing accessibility

Automated

We don’t have axe-core wired up yet. Budget item for the next quarter.

Manual

Every new screen goes through:

  1. Tab through everything. Can you reach every interactive element with Tab alone?
  2. Operate without a mouse. Can you complete the primary task keyboard-only?
  3. Close with Esc. Does every overlay close cleanly?
  4. Screen reader. Run VoiceOver (Mac) or NVDA (Windows). Does the structure make sense when read aloud?
  5. Zoom 200%. Does the layout hold? Any horizontal scroll? Any clipped text?
  6. Contrast checker. Run any screen that uses custom colors through a contrast checker.

Add these to the PR checklist for pages that touch accessibility-sensitive areas (dialogs, forms, bulk-action toolbars, etc.).


Common accessibility bugs in our code

Button with just an icon and no label

// BAD
<button onClick={...}><Trash /></button>
// GOOD
<Button tooltip="Delete row" size="icon-xs" variant="ghost">
<Trash aria-hidden="true" />
</Button>

Custom dropdown without keyboard nav

Use Radix DropdownMenu. Don’t build from scratch — you’ll miss ArrowDown / ArrowUp / Home / End / type-ahead.

Label with no association

// BAD
<div>Email</div>
<input ... />
// GOOD
<Label>Email</Label>
<Input id="email" />

Our <FormField> wires htmlFor automatically — use it.

onClick on non-interactive element

// BAD — div is not keyboard-focusable, not announced as a button
<div onClick={...}>Click me</div>
// GOOD
<Button onClick={...} variant="ghost">Click me</Button>

If you really need a non-button interactive element (rare): role="button" + tabIndex={0} + onKeyDown for Enter/Space. But this is a smell — use a Button.

Missing DialogTitle

// BAD — Radix warns in the console
<DialogContent>
<p>Delete this?</p>
<DialogFooter>...</DialogFooter>
</DialogContent>
// GOOD
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this agent?</DialogTitle>
<DialogDescription>...</DialogDescription>
</DialogHeader>
<DialogFooter>...</DialogFooter>
</DialogContent>

Accessibility checklist

Before shipping:

  • Every interactive element reachable via Tab.
  • Visible focus ring on all interactive elements.
  • Icon-only buttons have tooltip or aria-label.
  • Every form input has a <Label> (explicit or via <FormField>).
  • Dialogs have <DialogTitle> (visible or <VisuallyHidden>).
  • Empty states include icon + title + description + action (not just a sad face).
  • Error messages appear next to the field, with aria-describedby.
  • Esc closes dialogs, sheets, popovers, menus.
  • Color isn’t the only signal of status.
  • No fixed-pixel sizing that breaks at 200% zoom.
  • prefers-reduced-motion respected for decorative animation.
  • Contrast ≥ 4.5:1 for body text.
  • aria-current="page" on active sidebar item.
  • aria-live on mutation status surfaces (ActionButton already has this).
  • Keyboard shortcut overlay (?) lists every shortcut you added.

Next: 09-i18n-testing.md.