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)
| Key | Action |
|---|---|
⌘K / Ctrl+K | Open command palette |
? | Open keyboard shortcut overlay |
Esc | Close the topmost dialog/popover/menu |
Cmd+B | Toggle sidebar collapse |
/ | Focus the primary search input on the page |
Tab / Shift+Tab | Move focus forward / backward |
Enter | Activate focused button or link |
Space | Activate focused checkbox / switch / button |
List navigation
| Key | Action |
|---|---|
j / ↓ | Move to next item |
k / ↑ | Move to previous item |
Enter | Open / activate focused item |
Esc | Clear selection or leave list |
x | Toggle selection (when row-selection is enabled) |
Shift+Click | Range select |
Cmd+A | Select all |
Command palette entries and keyboard overlays should be discoverable — the ? overlay is not optional; every app implements it.
Form editing
| Key | Action |
|---|---|
Tab | Next field |
Shift+Tab | Previous field |
Enter | Submit form (on a text field) / activate button |
Esc | Cancel current edit (click-to-edit, inline) |
Cmd+Enter | Submit from within a <Textarea> |
Cmd+S | Save current form (if there’s an unambiguous Save action) |
Dialog / popover
| Key | Action |
|---|---|
Esc | Close (if closable) |
Tab | Cycle focus within the dialog (trap active) |
Enter | Activate 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).
| Scope | Key | Action | Owner file |
|---|---|---|---|
| Global | ⌘K | Command palette | AppLayout.tsx (when it ships) |
| Global | ? | Shortcut overlay | AppLayout.tsx (when it ships) |
| Global | / | Focus primary search | Each page-level search component |
| Global | ⌘B | Toggle sidebar | Sidebar.tsx |
| List / Inbox | j | Next item | <InboxList>, <DataTable> when row-selection active |
| List / Inbox | k | Previous item | same |
| Inbox | e | Archive current | <InboxList> |
| Inbox | ⌘↵ | Mark read + next | <InboxList> |
| List w/ selection | x | Toggle row selection | <DataTable> when selectable |
| List w/ selection | ⌘A | Select all | <DataTable> when selectable |
| Inline edit | ↵ | Commit | <InlineEditField> |
| Inline edit | Esc | Cancel | <InlineEditField> |
| Form (textarea) | ⌘↵ | Submit containing form | <ActionButton type="submit"> ancestor |
| Text-heavy page | ⌘S | Save (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-navj/kand the chordgdon’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
Enterworks 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. Neverreturnearly 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: nonewithout 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 / element | Attribute |
|---|---|
| Icon-only button | aria-label (or wrap in <Tooltip> — we do) |
| Icon in text (decorative) | aria-hidden="true" |
| Loading state | aria-busy="true" on the live region |
| Live updates (toast, SSE) | aria-live="polite" (our ActionButton does this) |
| Progress | role="progressbar" + aria-valuenow + aria-valuemax |
| Expanded state (accordion) | aria-expanded |
| Required form field | aria-required="true" |
| Field with error | aria-invalid="true" + aria-describedby="<errorId>" |
| Field with help text | aria-describedby="<helpId>" |
| Selected item (list) | aria-selected |
| Active tab | aria-selected="true" |
| Modal | role="dialog" + aria-modal="true" (Radix default) |
aria-label copy
- Buttons with visible labels: don’t add
aria-label(duplicates). - Icon-only buttons:
aria-labeldescribes 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 (ourActionButtonhas 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)
| Content | Minimum ratio |
|---|---|
| Normal text | 4.5 : 1 |
| Large text (≥ 18px bold or 24px regular) | 3 : 1 |
| UI component boundaries | 3 : 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-50which 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 hasaria-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"(ourActionButtonsets this). - The button text changes to
"Saving…"(announced viaaria-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".
Navigation
Keyboard-navigable nav
Every nav item is focusable with Tab. Arrow keys between items is nice-to-have but not required (sidebars are short).
Skip links
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.
Escto close.- Focus restore to trigger on close.
role="dialog",aria-modal="true",aria-labelledbyto 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 imagesInformative
<img src="avatar.jpg" alt="Jane Doe" /><span className="sr-only">Online</span> // invisible text for status indicatorsFunctional
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:
- Tab through everything. Can you reach every interactive element with Tab alone?
- Operate without a mouse. Can you complete the primary task keyboard-only?
- Close with Esc. Does every overlay close cleanly?
- Screen reader. Run VoiceOver (Mac) or NVDA (Windows). Does the structure make sense when read aloud?
- Zoom 200%. Does the layout hold? Any horizontal scroll? Any clipped text?
- 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
tooltiporaria-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. -
Esccloses dialogs, sheets, popovers, menus. - Color isn’t the only signal of status.
- No fixed-pixel sizing that breaks at 200% zoom.
-
prefers-reduced-motionrespected for decorative animation. - Contrast ≥ 4.5:1 for body text.
-
aria-current="page"on active sidebar item. -
aria-liveon mutation status surfaces (ActionButton already has this). - Keyboard shortcut overlay (
?) lists every shortcut you added.
Next: 09-i18n-testing.md.