Accessibility
WCAG 2.2 AA is the floor, not the goal. These are the constraints every component in this Bible was designed from — not a checklist run at the end.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Perceivable
Information must be available to at least one sense the user has. Text alternatives, 4.5:1 contrast, no meaning carried by colour alone, content that reflows at 320px.
Operable
Everything must work without a mouse. Full keyboard reach, visible focus, no traps, no time limits you cannot extend, targets of at least 44px.
Understandable
Predictable behaviour and plain language. Consistent navigation, labels that stay put, errors that say what went wrong and how to fix it.
Robust
Correct semantics so any assistive technology can interpret it — today’s screen readers and tomorrow’s. Native elements first, ARIA only to fill gaps.
Focus visibility
The most common accessibility regression in production code is a designer asking for the "ugly blue outline" to be removed. Restyle it; never delete it.
Error messages
An error must say what is wrong, why, and what a correct value looks like. "Invalid input" fails all three.
The keyboard contract
These bindings are the same in every component in this system. A user learns them once.
| Tab / Shift+Tab | Move between focusable elements in DOM order |
| Enter | Activate a button or link; submit a form |
| Space | Activate a button; toggle a checkbox; scroll the page |
| ↑ ↓ ← → | Move within a composite widget — tabs, radios, menus, listboxes |
| Home / End | Jump to the first or last item in a collection |
| Escape | Close the topmost overlay, one level at a time |
| Type-ahead | Jump to a matching option inside a listbox or select |
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Accessible name
What the control is called
Role
What kind of thing it is
State
checked, expanded, disabled, invalid
Value
Current value, min, max
Description
Supplementary help, wired with aria-describedby
The five things assistive technology needs from every interactive element. Native HTML supplies most of them automatically; ARIA fills the gaps.
- Accessible nameVisible text > aria-labelledby > aria-label
Prefer the visible label. Voice-control users say what they see, so a mismatch between the visible text and the accessible name makes the control unusable for them.
- RoleImplicit from the element
A <button> is role="button" with no attributes. Adding the role explicitly is redundant; adding a different role usually breaks something.
- Statearia-checked, -expanded, -selected
Must update the instant the visual does. A stale state attribute is worse than none because it actively misinforms.
- Valuearia-valuenow / -valuemin / -valuemax
For sliders, progress bars and meters. Omit valuenow entirely for indeterminate progress rather than sending 0.
- Descriptionaria-describedby
Announced after the name and role. Correct for help text and validation messages; wrong for anything the user must have before acting.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-focus-ring | — | The single focus indicator, used by every component |
| --ds-fg | — | Primary text, verified ≥13:1 in both themes |
| --ds-fg-secondary | — | Body copy, verified ≥7:1 |
| --ds-fg-muted | — | Captions, verified ≥4.6:1 — the tightest pair we ship |
| --ds-danger-text | — | Error text on a tinted fill, verified ≥4.5:1 |
Spacing
| Token | Value | Used for |
|---|---|---|
| touch-target | Minimum pointer target on coarse pointers | |
| target-spacing | Minimum clear space between adjacent targets | |
| focus-offset | Gap between the element and its focus ring |
Motion
| Token | Value | Used for |
|---|---|---|
| prefers-reduced-motion | Collapsed duration, so transitionend still fires |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Type | Max width | Touch target | When to use |
|---|---|---|---|---|---|
| Body text | — | ≥4.5:1 | — | — | Anything under 18.66px regular or 14px bold. |
| Large text | — | ≥3:1 | — | — | 18.66px+ regular or 14px+ bold. Do not design to the exception. |
| UI boundaries | — | ≥3:1 | — | — | Borders, icons and graphics that carry meaning. |
| Focus ring | — | ≥3:1 | — | — | Against both the element and the adjacent background. |
| Touch target | 44 × 44px | — | — | 44px | WCAG 2.2 AA minimum, with 8px of clear space. |
| Reflow | — | — | 320px | — | No horizontal scrolling at 320 CSS px, equal to 1280px at 400% zoom. |
*:focus { outline: none; }Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Body text 4.5:1. Large text and UI boundaries 3:1. Focus rings 3:1 against both neighbours.
- Alpha fills must be composited against their real background before measuring.
- Disabled controls are exempt — which is exactly why disabling is not an accessibility fix.
- Turn on Inspector Mode in this app and hover any text to see the live ratio.
Keyboard
| Tab | Every interactive element, in DOM order, with a visible ring. |
| Enter / Space | Activate. Enter also submits from inside a form field. |
| Arrows | Move within composite widgets. Tab moves between them, not inside them. |
| Escape | Dismiss the topmost layer and return focus to its trigger. |
| Home / End | First and last item in a collection. |
Screen readers
- Test with a real one. VoiceOver on macOS is Cmd+F5; NVDA on Windows is free. Thirty minutes of real use teaches more than any checklist.
- Headings are the primary navigation mechanism. Do not skip levels, and never style a paragraph to look like a heading.
- Landmarks — main, nav, aside, header, footer — let users jump between regions. A page of divs offers no way to skip anything.
- Announce only what changed. A live region that re-reads an entire table on every update is worse than silence.
Focus & touch
- One ring for the entire system, 2px solid at 2px offset, applied with :focus-visible. It must never be obscured by a sticky header — set scroll-padding-top on the scroll container (WCAG 2.4.11).
- 44 × 44 CSS pixels minimum with 8px of clear space, achieved with a pseudo-element overlay on coarse pointers so desktop density is unaffected.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-label | Controls with no visible text | Icon buttons only. Never to override a visible label. |
| aria-labelledby | Dialogs, regions, groups | Preferred over aria-label — it points at real, visible, translatable text. |
| aria-describedby | Fields with help or errors | Announced after the name. Multiple ids are allowed and are read in order. |
| aria-live | Async status regions | polite waits for a pause; assertive interrupts. Use assertive only for errors. |
| aria-expanded | Disclosure triggers | Must be on the trigger, not the panel, and must update synchronously with the visual state. |
| aria-current | The active nav item | page for navigation, step for wizards, true for anything else. |
| inert | Background behind a modal | Removes an entire subtree from focus and from the accessibility tree in one attribute. |
Example usage
1// 1. The right element, with no ARIA at all2<button type="button" onClick={save}>Save changes</button>34// 2. Icon-only controls need a name5<button type="button" aria-label="Close dialog">6 <X aria-hidden />7</button>89// 3. Fields: a real label, plus described-by for help and errors10<label htmlFor="email">Work email</label>11<input12 id="email"13 type="email"14 aria-describedby="email-help email-error"15 aria-invalid={Boolean(error)}16/>17<p id="email-help">We only use this for billing receipts.</p>18{error && <p id="email-error" role="alert">{error}</p>}1920// 4. Announce asynchronous changes21<div aria-live="polite" className="sr-only-ds">22 {results.length} results found23</div>2425// 5. Modals: trap focus, restore it, make the rest inert26useFocusTrap(open, panelRef)27useScrollLock(open)28<div role="dialog" aria-modal="true" aria-labelledby={titleId}>2930// 6. Skip link — the first focusable element on the page31<a href="#main" className="sr-only-ds focus:not-sr-only">Skip to content</a>CSS
/* One focus policy for the whole system */
:focus { outline: none; }
:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
/* Visually hidden but present in the accessibility tree.
display:none and visibility:hidden both remove it entirely. */
.sr-only-ds {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
/* Touch targets grow only where there is a coarse pointer */
@media (pointer: coarse) {
.control::after {
content: '';
position: absolute;
inset-inline: 0;
top: 50%;
block-size: 44px;
transform: translateY(-50%);
}
}
/* Sticky headers must not cover a focused element (WCAG 2.4.11) */
html { scroll-padding-block-start: 6.5rem; }
/* Collapse, do not remove */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Professional tips
- Unplug your mouse for an hour and use the product. It is the fastest accessibility audit that exists and it needs no tooling.
- Run axe DevTools in CI. Automated tools catch roughly 30–40% of issues, which is not everything but is the cheapest 40% you will ever get.
- Zoom to 400% and check that nothing scrolls horizontally. That is the actual WCAG reflow requirement and it fails more often than contrast does.
- Write the keyboard interaction into the ticket before writing the component. Retrofitting arrow-key navigation into a finished widget is always harder.
Performance
- A live region that updates on every keystroke floods the screen-reader queue. Debounce announcements to about 500ms.
- Very large accessibility trees slow screen readers down. Virtualise long lists and set aria-rowcount so the total is still announced.
- aria-hidden on a subtree is cheap; removing it from the DOM is cheaper. Prefer conditional rendering for anything genuinely absent.
- The inert attribute is more efficient than a JavaScript focus trap and handles pointer events too. Use it where support allows.
Common mistakes
- Using aria-label on a container, which overrides the accessible names of everything inside it.
- Putting role="button" on a div and adding a click handler, then forgetting tabindex and the Space key.
- aria-hidden="true" on a focusable element. The user can tab to something the screen reader claims does not exist.
- Positive tabindex values. They jump ahead of the natural order and break the page for everyone. Only 0 and −1 are ever correct.
- Announcing every state change as assertive, which interrupts the user mid-sentence and makes the app hostile to listen to.
Real-world recommendations
- Budget accessibility as part of the component, not as a follow-up ticket. Follow-up tickets are the ones that get cut.
- Include people with disabilities in usability testing. Automated checks and expert review both miss things that ten minutes of real use surfaces immediately.
- Publish a VPAT or accessibility statement. It is increasingly a procurement requirement, and writing it honestly forces the audit to actually happen.
- Keep a keyboard-only smoke test in CI: tab through the critical flow and assert that focus never lands on document.body. It catches an entire class of regression.