Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

POUR
P

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.

O

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.

U

Understandable

Predictable behaviour and plain language. Consistent navigation, labels that stay put, errors that say what went wrong and how to fix it.

R

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.

Visible focus ring2px solid, 2px offset, 3:1 against both
outline: noneKeyboard users are now lost

Error messages

An error must say what is wrong, why, and what a correct value looks like. "Invalid input" fails all three.

Says what and how
Says neither

The keyboard contract

These bindings are the same in every component in this system. A user learns them once.

Tab / Shift+TabMove between focusable elements in DOM order
EnterActivate a button or link; submit a form
SpaceActivate a button; toggle a checkbox; scroll the page
↑ ↓ ← →Move within a composite widget — tabs, radios, menus, listboxes
Home / EndJump to the first or last item in a collection
EscapeClose the topmost overlay, one level at a time
Type-aheadJump 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.

Focus visible
Body copy
AA text4.5:1
Heading
AA large3:1
44
Target44 × 44 min
Failed
RedundantIcon + colour + word
3 items selected
Announcedaria-live
durations → 1ms
Reduced motion
.sr-only-ds
Screen reader only

Anatomy

Every part, every measurement, and the reason it is that number.

1

Accessible name

What the control is called

2

Role

What kind of thing it is

3

State

checked, expanded, disabled, invalid

4

Value

Current value, min, max

5

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.

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

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

  3. Statearia-checked, -expanded, -selected

    Must update the instant the visual does. A stale state attribute is worse than none because it actively misinforms.

  4. Valuearia-valuenow / -valuemin / -valuemax

    For sliders, progress bars and meters. Omit valuenow entirely for indeterminate progress rather than sending 0.

  5. Descriptionaria-describedby

    Announced after the name and role. Correct for help text and validation messages; wrong for anything the user must have before acting.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
touch-targetMinimum pointer target on coarse pointers
target-spacingMinimum clear space between adjacent targets
focus-offsetGap between the element and its focus ring

Motion

TokenValueUsed for
prefers-reduced-motionCollapsed duration, so transitionend still fires

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightTypeMax widthTouch targetWhen 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 target44 × 44px——44pxWCAG 2.2 AA minimum, with 8px of clear space.
Reflow——320px—No horizontal scrolling at 320 CSS px, equal to 1280px at 400% zoom.

Do

<button type="button"><div onClick={…}>
Use the native elementA <button> gives you focus, Enter, Space, form participation, the correct role and voice-control support in every browser, for free. Everything else is you reimplementing it.
Make the accessible name match the visible labelWCAG 2.5.3. A voice-control user says "click Save"; if the aria-label is "Persist changes", nothing happens and there is no feedback explaining why.
aria-live="polite" — 12 results
Announce dynamic changesA screen-reader user does not see the row appear or the count change. An aria-live region is what makes an asynchronous update perceivable rather than silent.
Enter an email that includes an @ — for example, ada@example.com
Write errors that say what to doNaming the problem is half the job; showing a valid example is the other half. "Invalid input" is a dead end, and it is the most common error string in software.

Don't

*:focus { outline: none; }
Do not remove the focus outlineIt is the only way a keyboard user knows where they are. If it clashes with the design, restyle it — :focus-visible already hides it from mouse users.
Do not use a placeholder as a labelIt disappears the moment the user types, so anyone who is interrupted loses the field’s identity. It also usually fails contrast, and screen-reader support for it is inconsistent.
Do not convey state with colour aloneRoughly 300 million people cannot reliably distinguish red from green. Add an icon, a word, or a pattern — the greyscale test is the check.
Tab → an element behind the scrim → the user is now lost
Do not trap focus outside a modalTabbing out of a dialog into a page the user cannot see is completely disorienting. Trap inside modals; never trap anywhere else.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.1.1Non-text ContentA1.3.1Info and RelationshipsA1.4.3Contrast (Minimum)AA1.4.11Non-text ContrastAA2.1.1KeyboardA2.1.2No Keyboard TrapA2.4.3Focus OrderA2.4.7Focus VisibleAA2.4.11Focus Not ObscuredAA2.5.8Target Size (Minimum)AA3.3.1Error IdentificationA3.3.3Error SuggestionAA4.1.2Name, Role, ValueA

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

TabEvery interactive element, in DOM order, with a visible ring.
Enter / SpaceActivate. Enter also submits from inside a form field.
ArrowsMove within composite widgets. Tab moves between them, not inside them.
EscapeDismiss the topmost layer and return focus to its trigger.
Home / EndFirst 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.
AttributeApplied toNotes
aria-labelControls with no visible textIcon buttons only. Never to override a visible label.
aria-labelledbyDialogs, regions, groupsPreferred over aria-label — it points at real, visible, translatable text.
aria-describedbyFields with help or errorsAnnounced after the name. Multiple ids are allowed and are read in order.
aria-liveAsync status regionspolite waits for a pause; assertive interrupts. Use assertive only for errors.
aria-expandedDisclosure triggersMust be on the trigger, not the panel, and must update synchronously with the visual state.
aria-currentThe active nav itempage for navigation, step for wizards, true for anything else.
inertBackground behind a modalRemoves an entire subtree from focus and from the accessibility tree in one attribute.

Code

Example usage

tsx
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

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;
  }
}

Notes

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.