Canon / accessibility

Accessibility (a11y) — The Verdict

Accessibility is not a component or a final QA pass — it is a contract every token, component, and pattern must honor. WCAG 2.2 AA is the legal floor (what auditors, scanners, and courts test); APCA is the readability ceiling you tune toward. Build on native HTML semantics, add ARIA only to fill gaps, ship a visible focus ring, full keyboard operability, and respect user preferences (reduced motion, contrast, color scheme). Automated tools catch only ~30-40% of WCAG success criteria — manual + assistive-technology testing is non-negotiable.

TL;DR decision table

Concern Do this WCAG SC
Text contrast 4.5:1 body, 3:1 large (≥24px or ≥18.66px bold) 1.4.3
UI / icon / focus-ring contrast 3:1 against adjacent colors 1.4.11
Visible focus :focus-visible + --color-ring, 2px outline + 2px offset, never outline:none alone 2.4.7, 2.4.13
Pointer target size ≥24×24px (AA), aim ≥44×44px (AAA / Apple HIG) 2.5.8, 2.5.5
Keyboard Everything operable, no traps, logical order 2.1.1, 2.1.2
Semantics Native element first (<button>, <a>, <label>); ARIA only when no native exists 4.1.2
Forms Programmatic label + error association; never color-alone 1.3.1, 3.3.1, 1.4.1
Motion Honor prefers-reduced-motion; no >3 flashes/sec 2.3.3, 2.3.1
Don't rely on color Pair color with text/icon/shape 1.4.1

1. WCAG 2.2 AA — the essentials you actually ship

WCAG organizes around POUR: Perceivable, Operable, Understandable, Robust. AA is the target level for virtually all legislation worldwide (EN 301 549 / European Accessibility Act, ADA case law, Section 508). WCAG 2.2 (W3C Recommendation, Oct 2023) is the current stable version and adds 9 success criteria over 2.1 — the ones you will touch most:

New in 2.2 Level What it forces
2.4.11 Focus Not Obscured (Min) AA The focused element must not be entirely hidden by sticky headers/footers or overlays
2.4.13 Focus Appearance AAA Focus indicator min area + 3:1 contrast against both states (treat as the bar to aim for)
2.5.8 Target Size (Min) AA Pointer targets ≥ 24×24 CSS px (with spacing exceptions)
3.2.6 Consistent Help A Help mechanisms appear in the same relative order across pages
3.3.7 Redundant Entry A Don't make users re-enter info already given in the same process
3.3.8 Accessible Authentication (Min) AA No cognitive-function test (e.g. transcribe/puzzle) without an alternative; allow paste & password managers

3.3.8 in practice: never disable paste on password / OTP fields and never block password managers. That single rule fixes a huge class of real-world auth failures.


2. Color contrast — WCAG floor + APCA direction

WCAG 2.x ratios (the legal requirement)

A luminance-ratio model from 1:1 (no contrast) to 21:1 (black on white).

Content AA AAA
Body text (< 24px, or < 18.66px bold) 4.5:1 7:1
Large text (≥ 24px, or ≥ 18.66px bold) 3:1 4.5:1
UI components, icons, graphical objects, focus indicators 3:1
Disabled controls, pure decoration exempt exempt

Known weakness: WCAG 2 over-rates dark pairs, so it is a poor guide for dark mode — verify dark themes with APCA and real eyes. There is no background where both pure black and pure white fail AA; one always passes.

APCA — the readability ceiling (candidate, not law)

APCA (Advanced Perceptual Contrast Algorithm) is the candidate method for WCAG 3. It outputs a polarity- and size/weight-aware Lc value (practical range ≈ ±106). Status June 2026: APCA was moved out of the WCAG 3 working draft (July 2023); WCAG 3 is unlikely to finalize before ~2028-2030. Do not drop WCAG 2 conformance over a draft — even APCA's author says so.

Lc Use Rough WCAG map
90 Preferred body text
75 Minimum for body-text columns preferred
60 Min for non-body text ≈ 4.5:1
45 Large/heavy text, headlines, fine icons ≈ 3:1
30 Absolute min any text; placeholder/disabled
15 Min for non-text discernibility

Two-layer strategy: ship WCAG 2.2 AA as the floor, tune with APCA on top, and pick colors that pass both.

How to check contrast

  • In the build: axe-core / Lighthouse / Pa11y in CI flag text-contrast failures automatically (≈ the only WCAG area machines test well).
  • By hand: browser DevTools color picker shows the live ratio + AA/AAA pass; Chrome DevTools also exposes an experimental APCA readout (treat as informative).
  • Standalone: the OKLCH picker visualizes gamut; APCA's own calculators for Lc.
  • Token-time: CSS Color 5 contrast-color() (Baseline newly available, April 2026) returns black/white auto for any flat background — guarantees AA-ish, not AAA.
/* Let the browser pick legible foreground for any token-driven surface */
.chip {
  background: var(--color-accent);
  color: contrast-color(var(--color-accent)); /* auto black or white */
}
/* Progressive enhancement: ship a manual fallback for older engines */
.chip { color: var(--color-accent-fg); }
@supports (color: contrast-color(red)) {
  .chip { color: contrast-color(var(--color-accent)); }
}

Never communicate state by color alone (1.4.1). A required field, an error, a "selected" tab, a chart series — each needs a second cue: text, icon, underline, shape, or pattern.


3. Visible focus — the :focus-visible + --color-ring recipe

A visible focus indicator is the single most-impactful keyboard-a11y feature, and the most commonly destroyed one (outline: none). The rule: never remove focus without replacing it, and scope the ring to :focus-visible so mouse users don't see rings on click while keyboard users always do.

/* GLOBAL FOCUS RING — apply once, inherit everywhere */

/* 1. Kill the default ONLY for pointer focus, keep it for keyboard */
:focus:not(:focus-visible) {
  outline: none;
}

/* 2. The real, token-driven ring (3:1 against bg per 1.4.11) */
:focus-visible {
  outline: 2px solid var(--color-ring);
  outline-offset: 2px;        /* gap so the ring reads against the control */
  border-radius: inherit;      /* match the element's corners */
}

/* 3. Inputs/cards on busy backgrounds: double-ring for guaranteed contrast
      on ANY surface (light halo + dark line, or vice versa) */
.input:focus-visible {
  outline: 2px solid var(--color-ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-ring) 25%, transparent);
}

/* 4. High-contrast / forced-colors: opt into the system focus color */
@media (forced-colors: active) {
  :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
}
/* The ring token itself — defined in the color layer, recalled here.
   Must clear 3:1 against EVERY surface it can land on. */
:root      { --color-ring: oklch(0.62 0.20 264); }
.dark,
[data-theme="dark"] { --color-ring: oklch(0.72 0.21 264); } /* +L +C for dark */

Why outline over box-shadow as the primary ring: outline is not clipped by overflow: hidden, ignores border-radius clipping issues, and survives forced-colors mode. Use box-shadow only as an additive halo, never the sole indicator.

2.4.11 Focus Not Obscured: when you have a sticky header, give scrollable focus targets scroll-margin so they never hide under it.

:where(a, button, input, [tabindex]) { scroll-margin-block: var(--space-20); }

4. Keyboard navigation + focus management

Everything operable by mouse must be operable by keyboard, with no traps and a logical (DOM) order. Lean on native elements to get this for free.

Expected keyboard contract

Pattern Keys
Button / link Enter (and Space for <button>) activates
Tabs Tab to tablist, ←/→ move between tabs, Home/End jump
Menu / listbox ↑/↓ move, Enter/Space select, Esc close, type-ahead
Dialog (modal) Tab/Shift+Tab cycle inside dialog, Esc closes
Disclosure / accordion Enter/Space toggles
Combobox opens list, arrows navigate, Enter commits, Esc reverts

tabindex rules: use 0 to make a custom widget focusable in DOM order, -1 for programmatic-only focus (element.focus()), and never positive values — they break the natural order and are an anti-pattern.

Skip link (bypass blocks — 2.4.1)

<a class="skip-link" href="#main">Skip to main content</a>
<!-- ...nav... -->
<main id="main" tabindex="-1"> ... </main>
.skip-link {
  position: absolute;
  left: var(--space-2);
  top: var(--space-2);
  z-index: 999;
  padding: var(--space-2) var(--space-4);
  background: var(--color-surface);
  color: var(--color-fg);
  border-radius: var(--radius-md);
  /* Visually hidden until focused, then snaps into view */
  transform: translateY(-150%);
  transition: transform var(--dur-fast) var(--ease-out);
}
.skip-link:focus-visible { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }

Focus management for dialogs (trap + restore)

A modal dialog must: move focus in on open, trap focus inside, close on Esc and backdrop click, and restore focus to the trigger on close. The native <dialog showModal()> does most of this — prefer it.

"use client";
import { useEffect, useRef } from "react";

type DialogProps = {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
};

export function Dialog({ open, onClose, title, children }: DialogProps) {
  const ref = useRef<HTMLDialogElement>(null);
  const lastFocused = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (open) {
      lastFocused.current = document.activeElement as HTMLElement;
      el.showModal();              // native: focus trap + inert background + Esc
    } else if (el.open) {
      el.close();
      lastFocused.current?.focus(); // restore focus to the trigger (2.4.3)
    }
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby="dlg-title"
      onClose={onClose}                         // fires on Esc / close()
      onClick={(e) => { if (e.target === ref.current) onClose(); }} // backdrop
      className="dialog"
    >
      <h2 id="dlg-title">{title}</h2>
      {children}
      <button type="button" onClick={onClose}>Close</button>
    </dialog>
  );
}
.dialog { border: 1px solid var(--color-border); border-radius: var(--radius-xl);
          padding: var(--space-6); background: var(--color-surface);
          color: var(--color-fg); box-shadow: var(--shadow-xl); }
.dialog::backdrop { background: oklch(0 0 0 / 0.5); }

If you must build a custom (non-<dialog>) overlay, set aria-modal="true" + role="dialog", mark the rest of the page inert, and implement the trap + restore yourself. Native <dialog> exists precisely so you don't have to.


5. Semantic HTML first, ARIA only when needed

The first rule of ARIA: don't use ARIA. A native element with built-in semantics and behavior is always better than re-creating it with a <div> + roles. <button> gives you focusability, Enter/Space activation, and the correct role for free; a <div role="button"> makes you re-implement all of it (and most implementations are buggy).

Need Use this Not this
Click action <button type="button"> <div onClick>
Navigation <a href> <span onClick>
Section landmarks <header> <nav> <main> <aside> <footer> <div class="nav">
Heading hierarchy one <h1> per page, no skipped levels styling a <p> to look big
Toggle <input type="checkbox"> / <button aria-pressed> <div class="toggle">
List <ul>/<ol>/<li> stacked <div>s

The five ARIA rules (W3C ARIA Authoring Practices): (1) prefer native HTML; (2) don't change native semantics (<h2 role="button"> is wrong — wrap a button instead); (3) all interactive ARIA controls must be keyboard-operable; (4) never put role="presentation" / aria-hidden="true" on a focusable element; (5) every interactive element needs an accessible name.

<!-- Accessible names, in priority order -->
<button aria-label="Close dialog">✕</button>            <!-- icon-only: aria-label -->
<button aria-labelledby="t1">…</button><span id="t1">Save</span>
<img src="chart.png" alt="Q2 revenue up 18% over Q1" /> <!-- meaningful alt -->
<img src="divider.svg" alt="" />                         <!-- decorative: empty alt -->
<svg aria-hidden="true" focusable="false">…</svg>        <!-- decorative icon -->

Live regions announce async changes to screen readers without moving focus:

<!-- Polite: waits for a pause (toasts, autosave, search counts) -->
<div role="status" aria-live="polite" class="sr-only" id="toast-region"></div>
<!-- Assertive: interrupts immediately (errors, time-critical) -->
<div role="alert" aria-live="assertive"></div>

The screen-reader-only utility (visually hidden, still announced):

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
/* Make it visible again when a sr-only control receives focus (skip links) */
.sr-only-focusable:focus-visible {
  position: static; width: auto; height: auto;
  clip: auto; clip-path: none; white-space: normal;
}

6. Accessible forms — label + error association

Forms are where a11y most often breaks. Three hard requirements: every field has a programmatically associated label, errors are associated and announced, and validity is never signaled by color alone.

<form novalidate>
  <div class="field">
    <label for="email">Email</label>
    <input
      id="email"
      name="email"
      type="email"
      autocomplete="email"
      required
      aria-required="true"
      aria-invalid="true"
      aria-describedby="email-hint email-error"
    />
    <p id="email-hint" class="field__hint">We'll never share it.</p>
    <!-- role="alert" announces the error the moment it appears -->
    <p id="email-error" role="alert" class="field__error">
      <svg aria-hidden="true" class="icon">…</svg>
      Enter a valid email, e.g. name@example.com
    </p>
  </div>

  <button type="submit">Create account</button>
</form>

Rules baked into the markup above:

  • Label association: <label for>id. A wrapping <label> also works. Placeholder text is not a label (it disappears and fails contrast).
  • autocomplete tokens (1.3.5 Identify Input Purpose) let browsers/password managers fill fields — required for AA and they help everyone.
  • Error association: aria-describedby links the field to both hint and error; aria-invalid="true" marks the field; role="alert" makes the message announce on appearance.
  • Required: mark with required + visible text/* (with a legend explaining *), not color.
  • Error summary: for multi-error forms, render a focusable summary at the top (<h2 tabindex="-1"> + a list of in-page links to each field) and move focus to it on failed submit.
// Move focus to the error summary on a failed submit (focus management 3.3.1)
function onSubmitInvalid(summaryRef: React.RefObject<HTMLHeadingElement>) {
  summaryRef.current?.focus();
  summaryRef.current?.scrollIntoView({ block: "start" });
}
.field__error {
  display: flex; gap: var(--space-1); align-items: center;
  color: var(--color-danger-fg);
  font: var(--text-sm)/var(--leading-sm) var(--font-body);
}
/* Pair the danger color with a border AND an icon — never color alone */
.input[aria-invalid="true"] { border-color: var(--color-danger); }

7. Target size — 24px floor, 44px goal

WCAG 2.2 2.5.8 (AA) requires pointer targets ≥ 24×24 CSS px (or ≥24px of spacing between smaller targets). The AAA criterion 2.5.5 and Apple HIG both want 44×44 — that is the real comfort target for touch. Bake the floor into interactive tokens.

:where(button, [role="button"], a, input, select, summary) {
  min-block-size: var(--space-11);   /* 44px when --space-11 = 2.75rem */
  min-inline-size: var(--space-11);
}

/* Small inline controls (e.g. icon in dense table): keep a 24px hit area
   even when the glyph is smaller, via padding or a ::before overlay */
.icon-btn--dense {
  min-block-size: var(--space-6);    /* 24px visual */
  position: relative;
}
.icon-btn--dense::before {           /* expand the hit area to 44px */
  content: ""; position: absolute; inset: 50% 50%;
  inline-size: 44px; block-size: 44px; translate: -50% -50%;
}

Spacing exception: a 24px target is allowed if there's ≥24px clear space to neighboring targets. Don't rely on it — generous targets reduce mis-taps for everyone (motor impairments, small screens, fat fingers).


8. User preferences — reduced motion, contrast, color scheme

prefers-reduced-motion (2.3.3)

Reduce, don't always remove — swap large transforms/parallax for a quick opacity fade so the UI still feels intentional. Ship a global safety net, then refine per-component.

/* Global net: neutralizes runaway animation but keeps tiny crossfades */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
/* Kill morphs in View Transitions too */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}
// React: site-wide via MotionConfig; "user" keeps opacity/color, drops transforms
import { MotionConfig } from "motion/react";
<MotionConfig reducedMotion="user">{children}</MotionConfig>

// Per-component: swap transform for fade
import { useReducedMotion } from "motion/react";
function Drawer({ open }: { open: boolean }) {
  const reduce = useReducedMotion();
  const animate = open
    ? reduce ? { opacity: 1 } : { x: 0 }
    : reduce ? { opacity: 0 } : { x: "-100%" };
  return <motion.aside animate={animate} />;
}

Also disable smooth-scroll libraries (Lenis), autoplaying video, and infinite loops under reduced motion. 2.3.1 Three Flashes: never flash content more than 3×/second.

prefers-contrast + forced-colors

/* User asked for more contrast: strengthen borders and text */
@media (prefers-contrast: more) {
  :root {
    --color-border: var(--color-fg);
    --color-fg-muted: var(--color-fg);
  }
  .btn { border: 2px solid currentColor; }
}

/* Windows High Contrast / forced-colors: don't fight the system palette */
@media (forced-colors: active) {
  .btn { border: 1px solid ButtonText; }
  /* Restore meaning lost when bg images/box-shadows are stripped */
  .card { border: 1px solid CanvasText; }
  /* forced-color-adjust: none ONLY where you must preserve meaning (e.g. a
     status swatch) — pair with a text label so it survives anyway */
  .swatch { forced-color-adjust: none; }
}

color-scheme (correct native form controls in dark mode)

/* Tells the UA to render scrollbars, inputs, and built-in controls per theme */
:root { color-scheme: light dark; }
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"]  { color-scheme: dark; }

9. Japanese-text accessibility (日本語の a11y)

Latin-centric defaults degrade Japanese readability. Key adjustments:

  • Line height: CJK glyphs are dense and full-height — use line-height: 1.7-1.8 for Japanese body text (vs ~1.5 for Latin), so lines breathe.
  • No mid-word breaking by character class: use line-break: strict and overflow-wrap: anywhere carefully; rely on word-break: normal (do not use break-all for prose — it breaks kinsoku rules). Enable proper line-break rules with line-break: strict;.
  • Avoid justified text (text-align: justify) for Japanese unless you also set text-justify appropriately — uneven spacing harms low-vision readers.
  • Font weight & contrast: thin weights on dense kanji reduce legibility; keep body weight ≥ 400 and meet the same 4.5:1. Many kanji have fine strokes, so favor the higher end of contrast.
  • lang attribute: set <html lang="ja"> (or per-block lang) so screen readers pick the correct voice and so :lang() typography rules apply. Mixed JA/EN content should mark English spans lang="en".
  • Ruby for readings: use semantic <ruby> / <rt> for furigana rather than parenthetical hacks, so AT can expose or skip readings.
  • text-spacing-trim / font-feature-settings: modern browsers can trim full-width punctuation spacing (text-spacing-trim: trim-start) for tighter, more legible kana/kanji runs.
:lang(ja) {
  line-height: 1.75;
  line-break: strict;
  word-break: normal;
  overflow-wrap: anywhere;
  font-feature-settings: "palt" 0;        /* keep full-width metrics for body */
  text-spacing-trim: trim-start;          /* trim leading bracket/punct space */
}
:lang(ja) :is(h1, h2, h3) { line-height: 1.4; } /* headings can be tighter */
<p lang="ja">本日<ruby>晴天<rt>せいてん</rt></ruby>なり。<span lang="en">OK</span></p>

10. Accessible loading / empty / error states

Every async surface needs three states, each announced correctly — not just a spinner.

function DataPanel({ status, items, error }: PanelState) {
  if (status === "loading")
    // Announce politely; busy state for AT; skeleton, not just a spinner
    return (
      <div role="status" aria-live="polite" aria-busy="true">
        <span className="sr-only">Loading results…</span>
        <Skeleton rows={5} aria-hidden="true" />
      </div>
    );

  if (status === "error")
    // role="alert" interrupts; offer a keyboard-reachable recovery action
    return (
      <div role="alert" className="state state--error">
        <h3>Couldn't load results</h3>
        <p>{error.userMessage /* plain language, no codes */}</p>
        <button type="button" onClick={retry}>Try again</button>
      </div>
    );

  if (items.length === 0)
    // Empty ≠ error: explain + give the next action. Not announced as alert.
    return (
      <div className="state state--empty">
        <h3>No results yet</h3>
        <p>Try a different search or create your first item.</p>
        <button type="button" onClick={onCreate}>Create item</button>
      </div>
    );

  return <List items={items} />;
}
  • Loading: aria-busy="true" + a polite role="status" label. Decorative skeletons get aria-hidden.
  • Error: role="alert", plain-language message (what happened + how to fix), and a focusable retry.
  • Empty: distinct from error — explain and offer a clear next step. Don't announce as an alert.

11. Tailwind v4 — themed a11y utilities

shadcn / Tailwind defaults must be themed, not shipped raw. Wire the focus ring and target tokens into @theme so utilities inherit them.

@import "tailwindcss";

@theme {
  --color-ring: oklch(0.62 0.20 264);
  --color-danger: oklch(0.64 0.237 25);
  --color-danger-fg: oklch(0.98 0.02 25);
  --radius-md: 0.5rem;
}

/* Project-wide focus ring as a Tailwind base layer */
@layer base {
  :focus-visible {
    outline: 2px solid var(--color-ring);
    outline-offset: 2px;
  }
  :focus:not(:focus-visible) { outline: none; }
}
// Tailwind utility form of the ring (matches the CSS above)
<button
  className="min-h-11 min-w-11 rounded-md
             focus-visible:outline-2 focus-visible:outline-offset-2
             focus-visible:outline-[var(--color-ring)]
             motion-reduce:transition-none"
>
  Save
</button>

motion-reduce: and contrast-more: variants ship in Tailwind v4 — use them to honor prefers-reduced-motion / prefers-contrast inline. forced-colors: variant handles Windows High Contrast.


✅ Pre-ship a11y checklist

Perceivable

  • Text contrast ≥ 4.5:1 (body) / 3:1 (large); UI + icons + focus ring ≥ 3:1
  • No information conveyed by color alone (text/icon/shape backup)
  • All meaningful images have alt; decorative images have alt="" / aria-hidden
  • Content reflows / readable at 200% zoom and 320px width

Operable

  • Every interactive element reachable + operable by keyboard, no traps
  • Visible :focus-visible ring on every focusable element; outline:none never alone
  • Focus not obscured by sticky headers/overlays (2.4.11); scroll-margin set
  • Logical DOM/tab order; no positive tabindex
  • Skip link to <main>; one <h1>, no skipped heading levels
  • Pointer targets ≥ 24px (≥44px preferred)
  • Dialogs trap focus, close on Esc, restore focus to trigger
  • prefers-reduced-motion honored; nothing flashes >3×/sec

Understandable

  • Every form field has a programmatic label (not placeholder-only)
  • Errors associated (aria-describedby + aria-invalid) and announced (role="alert")
  • autocomplete tokens on identity fields; paste allowed on auth fields
  • Plain-language errors (what happened + how to fix); loading/empty/error states present
  • <html lang> set (lang="ja" for Japanese; English spans marked)

Robust

  • Native HTML semantics first; ARIA only to fill gaps; valid roles + names
  • Live regions for async updates (role="status" / role="alert")
  • color-scheme set; forced-colors mode survives (meaning not lost)
  • Tested with a screen reader (VoiceOver / NVDA) + keyboard-only, not just axe
  • axe-core / Lighthouse in CI (catches ~30-40% of SC — necessary, not sufficient)

Sources

Source file: design-system/accessibility/a11y.md