Skip to main content
Executive Support by Beige Threat

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.

What you can rely on
Contract 01
Colour combinations meet AA

4.5:1 for body text, 3:1 for large text and UI – verified for every semantic pair via the contrast matrix below.

Every approved pairing in the matrix carries its measured ratio. Anything that fails AA is removed from the system before it can be reached for.
Contract 02
Visible focus on every interactive element

A 2px teal ring with a 2px paper halo, set globally via :focus-visible. Never overridden, never hidden.

If a control can be reached with the keyboard, it shows where it is. The same ring, the same colour, on every surface – cream, navy, photograph.
Contract 03
Hit targets ≥ 24×24

WCAG 2.5.8 minimum. 44×44 preferred for touch. At least 8px between adjacent targets.

Icon-only buttons are padded out to 44×44, even when the icon itself is 20×20. The pointer-target is the button, not the glyph.
Contract 04
Motion respects user preference

prefers-reduced-motion: reduce collapses every duration token to 1ms automatically.

No component opts out. The token layer enforces it, so a designer or engineer can’t ship a page that ignores the setting by accident.

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.

Colour contrast ratio matrix for semantic token pairs
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.

Don't · Teal-300 on cream
A line of body text

1.45:1. Fails AA at any size. Teal as a body colour belongs at teal-700 or darker on cream.

Don't · Mustard-400 on paper
A line of body text

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.

Keyboard contract
Tab / Shift+Tab
Forward / reverse through interactive elements

Reading order – never tabindex > 0. Disabled controls are skipped natively via the disabled attribute.

tabindex=“0” or default
Enter / Space
Activate the focused control

Buttons accept either. Links accept Enter. Form submit buttons fire on Enter from any text input in the form.

click() equivalent
Esc
Close the topmost overlay

Modals, sheets, popovers, menus. Returns focus to the element that opened them.

close + restore focus
Arrow keys
Move within a composite control

Up/Down for vertical menus and listboxes; Left/Right for tablists and horizontal radios. Roving tabindex pattern.

role-aware navigation
Home / End
Jump to first / last in a composite

Inside listboxes, tablists, and grids. Optional but expected by power users.

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.

Focus tokens
--es-focus-ring-color
#06A0A9 · teal-600

Brand-locked. The only focus colour in the product. Same hue as the primary CTA – the simplest mental model: teal means action, focused or otherwise.

--es-focus-ring-width
2px

Sized for AA contrast at standard zoom. Visible on cream, paper, navy, and full-bleed photography because of the paper halo.

--es-focus-ring-offset
2px

Paper halo between element and ring – guarantees legibility regardless of background.

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.

24 × 24 · WCAG minimum
Inline icon-buttons in dense tables only
44 × 44 · Touch default
The standing rule for any pointer target
Hit-target rules
--es-space-2 (8px)
Minimum spacing between targets

Adjacent buttons, list rows, or icon controls keep 8px of clear space so a missed tap doesn't trigger the wrong action.

44×44 padded
Icon-only buttons

A 20px icon sits inside 12px of padding on each side. The visual is the icon, the target is the button.

Whole-row click
List rows and cards

The full row is the target – not just the headline link. 56px tall by default, never below 44px.

Issue 87 · The leadership issue

Screen readers

Naming, roles, and structure are the three things assistive tech listens for. Get those right and the rest follows.

Structural rules
One h1 per page
Hierarchy never skips levels

h1 → h2 → h3, in order. Skipping a level forces screen-reader users to guess at structure. Use CSS – not heading levels – to control visual size.

h1 → h2 → h3 → h4
Landmarks have labels
aria-label on every nav, aside, region

A page typically has two or more nav elements (primary, footer, breadcrumbs). Labels tell them apart.

aria-label=“Primary”
Form fields have labels
Programmatic, not just visual

<label for> or aria-labelledby. Placeholders are not labels – they vanish on focus and fail at low contrast.

<label for=“email”>
Decorative icons are hidden
aria-hidden on anything cosmetic

A chevron next to a link, a separator dot, an SVG flourish – screen-reader users don't need to hear them.

aria-hidden=“true”
Meaningful icons are named
aria-label on icon-only buttons

A magnifier icon by itself reads as 'button' to a screen reader. Give it a verb.

aria-label=“Search”
Do · Verbs on buttons

Tells the user, and the screen reader, exactly what’s about to happen.

Don't · Generic confirmations

Out of context, “OK” announces nothing. So does “Submit”, “Yes”, “Continue”.

Do · Labelled icon button

aria-label="Search the archive" on the button. aria-hidden="true" on the SVG.

Don't · Naked icon

Reads as “button”. Clickable, useless – the screen-reader user has no idea what it does.

Do · Heading hierarchy holds
h1 · The leadership issue
h2 · Long reads
h3 · The room she ran

h1 → h2 → h3, no skips. CSS handles the visual size; the markup carries the meaning.

Don't · Skipped levels
h1 · The leadership issue
h4 · The room she ran

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.

Don't · Scroll-driven parallax without a toggle
Layered images that drift past each other as the page scrolls.

Vestibular-disorder triggers, even at 1ms transitions. If a parallax has design value, gate it behind a control.

Don't · Auto-playing hero video
A 30-second loop above the fold, playing on load.

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.

Don't · Auto-advancing carousel
A row of issue covers that rotates every four seconds.

Steals focus from a screen reader, breaks the reading rhythm, and trains people to ignore it. Show a row that paginates on click.

Do · Always offer pause

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.

Language contract
lang on root
Always present, always accurate

<html lang='en-GB'> tells screen readers which voice and which pronunciation rules to use. Wrong lang attributes are a common, silent failure.

<html lang=“en-GB”>
lang on fragments
When the language changes mid-page

A French film title, an Italian neighbourhood, a quoted phrase – wrap them in a span with the right lang. Voiceover switches voice automatically.

<span lang=“fr”>Salon</span>
Flesch reading ease 60+
Lower-secondary level

Aim for sentences around 14 words, paragraphs of three to five lines, short common words. Test long copy in a Flesch-Kincaid analyser before shipping.

~ year-9 reading age
UK English
Default for our publication

Colour, organise, behaviour, programme. Note inline when content uses a different variety – e.g. a quoted American source.

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.

Form rules
Labels above
Visible, programmatic, not placeholder

Label the field with a real <label for>. Placeholders disappear on input and fail contrast at most luminances.

<label for> + <input id>
Helper & error tied
aria-describedby on the input

The helper text and the error live in elements with stable ids; the input lists them in aria-describedby. The screen reader reads label, value, then description.

aria-describedby=“email-help email-error”
aria-invalid + visible text
Never relying on red border alone

Colour can't carry the whole error – colour-blind users miss it. aria-invalid tells assistive tech; the message tells everyone what's wrong.

aria-invalid=“true”
Required is programmatic
required attribute, plus a visible asterisk

The asterisk is decorative – aria-hidden on it. The required attribute is what the screen reader and the form validator both consume.

required + <span aria-hidden>*</span>
autocomplete tokens
Always set the right value

autocomplete='email', 'name', 'street-address' lets browsers and password managers fill correctly – a major win for low-vision and motor-impaired users.

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.

Pre-ship checklist
01
Tab through the entire flow with VoiceOver / NVDA on

Every interactive element is reached, named, and announced. The order matches the visual layout. Esc closes overlays and returns focus.

02
Run axe / Lighthouse

Zero serious or critical issues. Treat each finding as a design bug to be fixed, not flagged.

03
Resize text to 200%

Cmd+Plus four times in the browser. Nothing clips, nothing overlaps, no horizontal scroll under 320px viewport width.

04
Toggle prefers-reduced-motion in DevTools

Rendering > Emulate CSS media feature. No animation runs longer than 1ms. No translate offsets fire.

05
Complete every primary task with the keyboard alone

Unplug the mouse. Subscribe, save, send, sign in. Anything you can't finish is a regression.

06
Toggle forced-colors mode

Windows High Contrast / forced-colors: active. Borders, focus rings, and text remain visible. Backgrounds become system colours.

Beyond AA

AA is the floor we never go beneath. AAA is the bar we reach for in two specific places.

Where we choose AAA
Body text contrast 7:1
Long-form articles

ink (#080305) on paper (#FDFDFD) measures 19.6:1. We hit AAA on every body pairing in the matrix because the publication is long-read. Cost: nothing. Benefit: low-vision readers don't have to switch to a reader app to finish the issue.

≥ 7:1 on body
Reading-mode toggle
On long-form articles

A control that drops the chrome, raises type to 18px minimum, widens the measure to 65ch, and uses ink-on-paper only. Same content, plain shell.

.es-reading-mode
Where AA is enough
Editorial accents
Mustard and orange in spreads

Pushing every accent to AAA would force us to stone-900 and lose the warmth that defines the brand. AA is the floor; the editorial colour palette earns its place there.

≥ 4.5:1 on text · ≥ 3:1 on UI
Hero photography overlays
Image plus headline

A 70% navy gradient under display type clears AA at 18.66px+. Pushing it darker would mute the photograph. Don't.

≥ 3:1 large text

Usage rules

Do · :focus-visible globally

The ring lights up for keyboard users and stays out of the way of mouse clicks. Set once, on every focusable element.

Don't · outline: none with no replacement

Fails WCAG 2.4.7. Keyboard users lose their place entirely – on a long form, that’s the whole submission.

Do · 44×44 pointer targets

Comfortable for thumbs, tremor-friendly, 8px between targets so a missed press doesn’t activate the wrong control.

Don't · 16×16 chevrons

Below the WCAG 2.5.8 floor and 2px apart. Unusable on touch, hostile to anyone with a tremor.

Do · Error: colour plus message

Add the rest of the address after the @.

Border, message, and aria-invalid="true". Anyone using colour-only cues still gets the meaning.

Don't · Red border alone

A colour-blind user sees an input. A screen-reader user hears an input. Neither knows it failed.

Do · Skip link on first Tab

Invisible until first Tab. Saves keyboard and screen-reader users five-to-ten Tab presses on every page.

Don't · No skip link – tab through the masthead every time
Logo · Issues · Long reads · Subscribe · Sign in · Search · Settings · …

A keyboard user starts every page in your masthead. By page three they’ve quit.

Do · Verb-first button labels

Out of context, “Save draft” is unambiguous. So is “Send to editor”. The screen reader announces what the button does.

Don't · Generic button labels

“OK” out of context tells nobody anything. The dialog title sometimes does – sometimes it doesn’t.

Do · Decorative icon hidden

The chevron repeats what the link text already says. aria-hidden="true" keeps it from being announced.

Don't · Decorative icon read aloud

Now the screen reader says “Read the issue, image, arrow”. The arrow adds nothing – hide it.