Part 2 · 2 chapters · ~20 min

Accessibility in Depth: Colour, Motion, Forms, Live Regions

Contrast as a computed ratio with its thresholds and where it fails, meaning beyond colour, motion and the user's preferences including forced colours; then forms with real labels, up-front instructions, associated and focused errors, and live regions that announce what changed away from focus.

5

Colour, contrast and motion

a number, a rule, and the user's control
  1. The computation: linearise each sRGB channel, take relative luminance (0.2126 R + 0.7152 G + 0.0722 B: green dominates, blue barely counts), and divide (L_light + 0.05) by (L_dark + 0.05). From 1:1 to 21:1. The DevTools colour picker computes it live.
  2. The thresholds: 4.5:1 for body text; 3:1 for large text (24px, or 18.66px bold); 3:1 for UI component boundaries and states, focus rings and meaningful graphics (1.4.11, the one teams forget); 7:1 at AAA. Disabled controls are exempt (not a licence to make them invisible); placeholders are text.
  3. Where it fails: muted metadata on a tinted surface; text over images or gradients (measure the worst pixel or add a scrim); brand colours as small text; a focus ring the same hue as its button; saturated accents in dark mode; hover states that lower contrast. The Design course part 1's enumerated token pairs catch the pairs; images need a scrim token or a manual check.
  4. Meaning beyond colour (1.4.1): an error is a red border plus an icon plus text; a negative amount has a sign (the Trust course part 4); a chart series has a label or a pattern; a link in prose is underlined. The test is greyscale: anything that loses meaning was colour alone.
  5. Motion: prefers-reduced-motion removes parallax, large transitions and decorative animation and keeps fades and essential feedback (the Design course's motion tokens resolve to zero); nothing flashes more than three times a second (2.3.1); anything moving for more than five seconds automatically can be paused (2.2.2: carousels, tickers, the trading tape).
  6. Other preferences: prefers-contrast: more (a high-contrast theme); forced-colors: active (Windows high contrast: use currentColor for icons and borders; never carry meaning in backgrounds or shadows; test it); prefers-color-scheme. Each answered once at the token layer.
COLOUR, CONTRAST AND MOTION
the ratio and how it is computed, meaning beyond colour, and motion the user controls
swipe the figure sideways, or tap expand for full screen
1/6
the computation
The computation: each sRGB channel is linearised (c / 12.92 for small values, ((c + 0.055) / 1.055)^2.4 otherwise), relative luminance L = 0.2126 R + 0.7152 G + 0.0722 B (green dominates: the eye is most sensitive to it), and the ratio is (L_light + 0.05) / (L_dark + 0.05). #6b655c on #ffffff is about 5.8:1 (passes body); #a9a297 on #ffffff is about 2.5:1 (fails even large text); white on #2563EB is about 5.2:1 (passes).
6

Forms and live regions

code
// an accessible field and a live region: labelled, described, invalid-associated, focus on submit, announced counts
function AccountField({ value, onChange, error }: Props) {
  const id = useId()
  const describedBy = [`${id}-hint`, error && `${id}-err`].filter(Boolean).join(' ')
  return (
    <div className="field">
      <label htmlFor={id}>Account number <span aria-hidden="true">*</span></label>
      <p id={`${id}-hint`} className="hint">10 digits, no spaces</p>
      <input id={id} inputMode="numeric" autoComplete="off" required value={value} onChange={onChange}
             aria-invalid={error ? true : undefined} aria-describedby={describedBy} />
      {error && <p id={`${id}-err`} className="error"><Icon name="warning" aria-hidden /> {error}</p>}
    </div>
  )
}
function onSubmit(e: FormEvent<HTMLFormElement>) {
  e.preventDefault()
  const errors = validate(form)
  if (errors.length) { setErrors(errors); requestAnimationFrame(() => document.getElementById(errors[0].fieldId)?.focus()); return }   // focus the first invalid field
  submit()
}
// a status region: present (empty) from first render; text set when the event happens
function ResultCount({ count, loading }: { count: number; loading: boolean }) {
  return <div role="status" className="visually-hidden">{loading ? '' : `${count} matching ${count === 1 ? 'account' : 'accounts'}`}</div>
}
// .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap }   // in the tree, not on screen
labelled, described, associated, announced
  1. Labels: a label element by for/id or aria-labelledby; never a placeholder alone; visible, so the name contains the visible text for voice-control users (2.5.3); fieldset and legend for groups; autocomplete attributes for input purpose (1.3.5) and free autofill.
  2. Instructions and required: format and constraints in the label or an aria-describedby hint shown before typing; required in both channels (the attribute and a visible marker explained once). The user should not have to fail to learn the format.
  3. Errors: identified in text next to the field (3.3.1), aria-invalid with aria-describedby to the message, focus moved to the first invalid field on submit (or to a linked summary on long forms), the message saying what to do (3.3.3); financial submissions reviewed or reversible (3.3.4, the Trust course's review step).
  4. Live regions: role="status" (polite), role="alert" (assertive), aria-live on a container, role="log" for appended sequences; present and empty from first render, text set when the event happens; clear then set to re-announce; aria-atomic for whole reads.
  5. Where they are used: result counts, toasts (never taking focus), background saves, a meaningful balance update (polite, never per tick), timeout warnings (alert, with a way to extend: 2.2.1), loading finished, SPA route changes. Not for keystrokes, decoration or per-second values.
  6. Testing: the Accessibility pane per field; submit an empty form with a screen reader on and listen for each error and the focus; trigger each region and listen for once, the right politeness, the right words. axe catches missing labels and broken references; only listening catches a region that never speaks or never stops.
the exercise
View your product in greyscale, then with reduced motion and forced colours on, then submit its main form empty with VoiceOver running. Four passes, ten minutes; each finding is a token, a primitive or a form pattern to fix once.
FORMS AND LIVE REGIONS
labels, instructions, errors associated and announced, and the regions that speak when the page changes on its own
swipe the figure sideways, or tap expand for full screen
1/6
labels
Labels: a label element associated by for/id (or wrapping the input), or aria-labelledby; never a placeholder alone (it disappears on typing, often fails contrast, and is not reliably a name); visible labels so voice-control users can say "click Amount" (2.5.3: the accessible name contains the visible label). Group related controls with fieldset and legend (a radio group's question is the legend). autocomplete attributes (name, email, tel, cc-number) identify input purpose (1.3.5) and let browsers fill.