Home/Foundations/Accessibility

Accessibility

Operators work this product all day. The system has to be usable by keyboard, by screen reader, at low contrast, at low motion. These are the rules every component obeys — measured, not asserted.

WCAG 2.2 AA target Measured contrast prefers-reduced-motion

Keyboard navigation

Every interactive element is reachable and operable with the keyboard alone. Patterns follow WAI-ARIA Authoring Practices.

KeyAction
TabMove focus to the next focusable element in document order.
Shift + TabMove focus to the previous focusable element.
EnterActivate the focused button, link, or default action (e.g. submit a form).
SpaceActivate the focused button, toggle a checkbox or switch, scroll a region.
EscClose the topmost overlay — popover, modal, sheet, dropdown, command palette.
Move within a composite widget: dropdown menu, listbox, combobox, segmented control (vertical), radio group.
Move within tabs, horizontal segmented control, calendar grid (day-by-day), date picker.
Home / EndJump to the first / last option in a listbox, menu, or calendar row.
PageUp / PageDownMove by month in calendars; by viewport in long lists.
Type-aheadIn menus, listboxes, and selects, typing a letter jumps to the first matching option.

Every component page has its own keyboard table covering its specific shortcuts. If a key is documented there, it is wired.

Focus management

Focus is never invisible. It moves predictably, returns predictably, and never traps the operator anywhere they didn't ask to be.

  • :focus-visible. Focus rings render only on keyboard focus — never on mouse click. The ring is a 2 px solid --brand outline with a 3 px --focus-ring-soft halo.
  • Programmatic focus on overlays. Opening a modal, sheet, or alert dialog moves focus to the first focusable control inside. Closing it returns focus to the trigger.
  • Focus trap inside dialogs. While a modal is open, Tab cycles within it. Esc closes and restores focus.
  • Roving tabindex. Composite widgets (tabs, menus, listboxes, segmented control) expose exactly one tab stop; arrow keys move focus within.
  • Skip links. Page shells expose a "Skip to content" link as the first focusable element.

Don't suppress the outline

outline: none without an equivalent :focus-visible replacement is a release blocker. Audit lints will fail.

Screen-reader patterns

Status, name, role, and value reach AT users. Visual-only state is a bug.

Live regions

Asynchronous updates announce themselves through aria-live:

  • aria-live="polite" — toasts, saved confirmations, sync results, validation that doesn't block.
  • aria-live="assertive" — destructive errors, connection loss, permission denial. Reserve for moments the operator must hear immediately.
  • role="status" — implicit polite region for progress and "Saved" messages.
  • role="alert" — implicit assertive region for error banners.

Hidden vs visible text

  • Visually hidden, screen-reader accessible — use .sr-only for labels that are obvious visually but need a name for AT (e.g. icon-only buttons).
  • Hidden from everyone — use hidden or display: none.
  • Decorative — apply aria-hidden="true" to elements that add no information (decorative icons, the chevron next to a label).

Naming and grouping

Form controls always have a <label for> or aria-labelledby. Sections use aria-labelledby pointing at their heading. Buttons that only contain an icon use aria-label.

Contrast ratios

Measured against WCAG 2.2. AA requires 4.5:1 for body text, 3:1 for large (18 pt+ or 14 pt bold) and UI components. AAA requires 7:1 / 4.5:1.

ForegroundBackgroundPairRatioWCAG
--text-strong --canvas Headings on page 16.71 : 1 AAA
--text-strong --surface Headings on cards 17.76 : 1 AAA
--text-body --canvas Body text on page 13.52 : 1 AAA
--text-body --surface Body text on cards 14.37 : 1 AAA
--text-muted --canvas Descriptions, helpers 6.31 : 1 AA
--text-muted --surface Descriptions on cards 6.71 : 1 AA
--text-subtle --surface Metadata, timestamps 3.16 : 1 AA Large only
--brand-strong / --text-link --surface Links, current nav 8.02 : 1 AAA
--brand-strong --brand-soft Active sidebar item 6.96 : 1 AA
#FFFFFF --brand Primary button text 5.47 : 1 AA
#FFFFFF --tone-danger Destructive button 5.71 : 1 AA
--tone-success --surface Success badge text 3.84 : 1 AA Large / UI
--tone-warn --surface Warn badge text 3.84 : 1 AA Large / UI

Known limitations: --text-subtle on --canvas falls just under 3:1 (2.98:1) and is reserved for timestamps and metadata at 14 px+ on white only. Success and warn tones meet UI-component contrast (3:1) but should never be the sole indicator of state — pair with an icon.

Reduced motion

When the OS reports prefers-reduced-motion: reduce, the system suppresses non-essential animation globally.

The token sheet (tokens.css) ships this rule unconditionally:

@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }

What this means in practice:

  • Entrance animations (.anim-fade-up, .anim-scale-in) snap to their final frame.
  • State transitions (hover, focus, popover open) become instantaneous.
  • Auto-scroll behaviour (e.g. anchor jumps) becomes a hard jump.
  • The pulse / spinner indicators still rotate — they communicate liveness — but at a single tick rather than a continuous loop.

Do not author bespoke @keyframes animation without an equivalent reduced-motion fallback. If the animation conveys meaning (e.g. a progress bar filling), provide a static alternative such as a numeric percentage.

Target sizes

WCAG 2.2 SC 2.5.5 (AA) requires interactive targets to be at least 24×24 CSS pixels, with exemptions for inline targets and equivalents.

44
--h-control-lg · 44 pxPrimary mobile target. Meets WCAG, Apple HIG (44), and Material (48 with surrounding space).
36
--h-control-md · 36 px (default)Default control height. Comfortably above WCAG 2.5.5 (24 px). Used for desktop buttons, inputs, dropdown rows.
30
--h-control-sm · 30 pxDense desktop tables and toolbars. Still above the 24 px floor.
24
--h-control-xs · 24 pxFloor. Reserved for icon-only chip buttons inside data tables. Must have at least 4 px of clear space around the target.

The default 36 px control height is a deliberate choice for a dense ERP context — every operator screen carries dozens of controls, and 44 px everywhere would cost a meaningful amount of vertical density. The system meets WCAG 2.5.5 (24 px AA floor) at every size and exceeds the Material 3 recommendation (40 px target, 48 px with spacing) at --h-control-lg, which mobile-first surfaces use.

Color is not a label

Never rely on color alone to communicate state. Pair every tonal signal with an icon, text, or shape.

Posted Completed 2 min ago
Needs input 3 fields outstanding
Failed Connection refused
Do

Pair the success tone with a check icon and the word "Posted". Three reinforcing signals.

Don't

Show a green dot alone. A red-green colorblind operator sees no difference between "Posted" and "Failed".

Decorative vs semantic icons

If an icon adds information, name it. If it decorates a label, hide it.

CaseMarkupWhy
Icon next to a text label <span data-icon="check" aria-hidden="true"> The label already names the action; the icon is decoration.
Icon-only button <button aria-label="Close"><span data-icon="x" aria-hidden="true"></button> The button needs a name; the icon itself stays hidden from AT to avoid a duplicate announcement.
Status icon (e.g. "verified") <span data-icon="check" role="img" aria-label="Verified"> The icon carries information that no nearby text duplicates.
Dotted state indicator <span class="dot" aria-hidden="true"></span> Posted The visible word names the state; the dot is decoration.

The rule: a screen reader user must hear exactly one name per control. Never zero, never two.

Do & don't

Do

Wire every interactive element to the keyboard before it ships. The keyboard table on each component page is the contract.

Don't

Trap focus accidentally. Always provide Esc and a visible close affordance for any overlay.

Do

Pair every color-coded state with an icon and a word.

Don't

Assert "WCAG AA" without measurement. Use the contrast table above as proof.