Accessibility
WCAG 2.2 AA is the floor, not the goal. The goal is something a tired chief of staff can read on her phone in a hotel lobby at 11pm – with the brightness turned down, two languages on the device, one hand free, and a cab outside. Accessibility is the part of the system that makes that possible. We treat it as design, not remediation.
The contract
Four guarantees the system makes – on every page, in every component, before a feature ships.
Colour & contrast
AA asks for 4.5:1 on body text and 3:1 on large text (≥ 18.66px bold, ≥ 24px regular) and on non-text UI – borders, icons, focus rings. Each semantic pair below is measured against those thresholds. Anything that fails is removed from the palette, not flagged with a warning.
| Pair | Foreground | Background | Ratio | Body AA 4.5 | Large AA 3.0 |
|---|---|---|---|---|---|
| Body on paper | text.primary | surface.page | 20.13 : 1 | Pass | Pass |
| Body on cream | text.primary | surface.sunken | 15.99 : 1 | Pass | Pass |
| Body on white card | text.primary | surface.raised | 20.48 : 1 | Pass | Pass |
| Secondary on paper | text.secondary | surface.page | 6.40 : 1 | Pass | Pass |
| Tertiary on paper | text.tertiary | surface.page | 4.22 : 1 | Fail | Pass |
| Headline on paper | text.brand | surface.page | 16.24 : 1 | Pass | Pass |
| Headline on cream | text.brand | surface.sunken | 12.90 : 1 | Pass | Pass |
| Link on paper | text.link | surface.page | 6.17 : 1 | Pass | Pass |
| Link hover on paper | text.linkHover | surface.page | 9.57 : 1 | Pass | Pass |
| Reversed text on navy | text.onInverse | surface.inverse | 16.24 : 1 | Pass | Pass |
| Button label · primary | action.primary.text | action.primary.default | 3.18 : 1 | Fail | Pass |
| Button label · secondary | action.secondary.text | action.secondary.default | 16.24 : 1 | Pass | Pass |
| Button label · tertiary | action.tertiary.text | surface.page | 20.13 : 1 | Pass | Pass |
| Info text on info surface | feedback.info.text | feedback.info.surface | 15.01 : 1 | Pass | Pass |
| Success text on success surface | feedback.success.text | feedback.success.surface | 13.20 : 1 | Pass | Pass |
| Warning text on warning surface | feedback.warning.text | feedback.warning.surface | 13.23 : 1 | Pass | Pass |
| Danger text on danger surface | feedback.danger.text | feedback.danger.surface | 13.66 : 1 | Pass | Pass |
Combinations to avoid – they look fine in a swatch and break the moment they touch real copy.
1.45:1. Fails AA at any size. Teal as a body colour belongs at teal-700 or darker on cream.
2.31:1. Mustard sits in the wrong luminance band for text – use mustard-700 or push it to a surface tint with ink on top.
The approved pairings live in the colour page. When in doubt, body text is text.primary on surface.page (16.7:1) or text.brand on surface.sunken (12.4:1). Reach for tinted text only when you’ve measured it.
Keyboard
Every interactive control reaches the keyboard. The order is the visual reading order – top to bottom, left to right – and never depends on tabindex greater than 0. Esc closes overlays. Enter and Space activate. Arrow keys move within composite controls (menus, tablists, listboxes, radio groups). Shift+Tab reverses.
tabindex=“0” or default click() equivalent close + restore focus role-aware navigation first / last item Every page begins with a skip-link. It’s invisible until first Tab and jumps the reader past the masthead and primary nav into the content. Keyboard users save five to ten Tab presses per page – screen-reader users save the equivalent in announcements.
<a class="es-skip-link" href="#main">Skip to content</a>
<header>…</header>
<main id="main" tabindex="-1">…</main>
.es-skip-link {
position: absolute;
inset-inline-start: var(--es-space-4);
inset-block-start: var(--es-space-4);
padding: var(--es-space-2) var(--es-space-3);
background: var(--es-color-paper);
color: var(--es-color-ink);
border-radius: var(--es-radius-sm);
transform: translateY(-200%);
transition: transform var(--es-motion-state);
}
.es-skip-link:focus-visible {
transform: translateY(0);
box-shadow: var(--es-focus-ring);
}
Focus rings
One ring across the system, set globally on a, button, input, select, textarea, [tabindex] via :focus-visible. Teal-600, 2px stroke, 2px paper-coloured halo. Never outline: none without a replacement. Never a per-component focus colour. The full mechanics live on the states page – the rules below are the accessibility contract that page is built to satisfy.
offset 2px :where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: none;
box-shadow: var(--es-focus-ring);
}
:focus-visible is non-negotiable. :focus (without -visible) lights up the ring on every mouse click and trains users to ignore it; :focus-visible only fires for keyboard navigation. The two pseudo-classes look the same in DevTools – they behave very differently in a real browser.
Hit targets
Pointer targets carry a non-text contrast contract too: they need enough physical area for a finger or a tremor-prone hand. WCAG 2.5.8 sets the floor at 24×24 CSS pixels. We adopt 44×44 for touch surfaces (iOS HIG, Android Material) and 24×24 only for inline icon-buttons embedded in dense tables, where 8px of clear space sits around them.
Inline icon-buttons in dense tables only
The standing rule for any pointer target
Screen readers
Naming, roles, and structure are the three things assistive tech listens for. Get those right and the rest follows.
h1 → h2 → h3 → h4 aria-label=“Primary” <label for=“email”> aria-hidden=“true” aria-label=“Search” Tells the user, and the screen reader, exactly what’s about to happen.
Out of context, “OK” announces nothing. So does “Submit”, “Yes”, “Continue”.
aria-label="Search the archive" on the button. aria-hidden="true" on the SVG.
Reads as “button”. Clickable, useless – the screen-reader user has no idea what it does.
h1 → h2 → h3, no skips. CSS handles the visual size; the markup carries the meaning.
h1 → h4 leaves the screen-reader user wondering what they missed. Use h2.
<nav aria-label="Primary">…</nav>
<nav aria-label="Footer">…</nav>
<button aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<label for="email">Email address</label>
<input id="email" type="email" autocomplete="email" required
aria-describedby="email-help" />
<p id="email-help">We send eight issues a year. Cancel any time.</p>
Motion sensitivity
prefers-reduced-motion: reduce flips every duration token to 1ms and every translate distance to 0px. Components built on the motion tokens inherit this for free – which is the point. Reduced motion is not a stripped-down second design; it’s the same design, with the transitions cut.
/* From motion.css – the system enforces this layer-deep */
@media (prefers-reduced-motion: reduce) {
:root {
--es-duration-instant: 1ms;
--es-duration-fast: 1ms;
--es-duration-base: 1ms;
--es-duration-slow: 1ms;
--es-duration-slower: 1ms;
--es-duration-deliberate: 1ms;
--es-stagger-tight: 0ms;
--es-stagger-default: 0ms;
--es-stagger-loose: 0ms;
--es-distance-xs: 0px;
--es-distance-sm: 0px;
--es-distance-md: 0px;
--es-distance-lg: 0px;
}
}
What we never ship, even with reduced motion respected.
Vestibular-disorder triggers, even at 1ms transitions. If a parallax has design value, gate it behind a control.
WCAG 2.2.2 caps auto-playing motion at 5 seconds without a pause control. We don’t auto-play at all – open with a still and let the reader press play.
Steals focus from a screen reader, breaks the reading rhythm, and trains people to ignore it. Show a row that paginates on click.
If motion has to run, the user can stop it. The pause control is keyboard-reachable and labelled.
Language & reading level
The voice rules live on the voice page. The accessibility layer adds three more requirements on top: programmatic language declarations, an explicit reading-age target, and a posture on which English we publish in.
<html lang=“en-GB”> <span lang=“fr”>Salon</span> ~ year-9 reading age en-GB · Guardian style <html lang="en-GB">
<body>
<p>
We met at a café in
<span lang="fr">le 11<sup>e</sup></span>
on a Tuesday.
</p>
</body>
</html>
Forms accessibility
Forms are where most accessibility failures land – unlabelled fields, error messages tied to nothing, red borders carrying the entire weight of “this is wrong”. The contract is plain.
<label for> + <input id> aria-describedby=“email-help email-error” aria-invalid=“true” required + <span aria-hidden>*</span> autocomplete=“email” <div class="es-field">
<label for="email">
Email address
<span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
autocomplete="email"
required
aria-describedby="email-help email-error"
aria-invalid="true"
/>
<p id="email-help" class="es-field-help">We send eight issues a year.</p>
<p id="email-error" class="es-field-error">
Enter an email address that includes an @.
</p>
</div>
The full pattern lives on the forms page – this is the accessibility floor every form variant has to clear.
Testing
Before any feature ships, the designer or engineer responsible runs through this checklist. Treat each finding as a design bug, not a polish task.
Beyond AA
AA is the floor we never go beneath. AAA is the bar we reach for in two specific places.
≥ 7:1 on body .es-reading-mode ≥ 4.5:1 on text · ≥ 3:1 on UI ≥ 3:1 large text Usage rules
The ring lights up for keyboard users and stays out of the way of mouse clicks. Set once, on every focusable element.
Fails WCAG 2.4.7. Keyboard users lose their place entirely – on a long form, that’s the whole submission.
Comfortable for thumbs, tremor-friendly, 8px between targets so a missed press doesn’t activate the wrong control.
Below the WCAG 2.5.8 floor and 2px apart. Unusable on touch, hostile to anyone with a tremor.
Add the rest of the address after the @.
Border, message, and aria-invalid="true". Anyone using colour-only cues still gets the meaning.
A colour-blind user sees an input. A screen-reader user hears an input. Neither knows it failed.
Invisible until first Tab. Saves keyboard and screen-reader users five-to-ten Tab presses on every page.
A keyboard user starts every page in your masthead. By page three they’ve quit.
Out of context, “Save draft” is unambiguous. So is “Send to editor”. The screen reader announces what the button does.
“OK” out of context tells nobody anything. The dialog title sometimes does – sometimes it doesn’t.
The chevron repeats what the link text already says. aria-hidden="true" keeps it from being announced.
Now the screen reader says “Read the issue, image, arrow”. The arrow adds nothing – hide it.