Agents Playbook

Accessibility Checklist

What every shipped surface passes before merge, and how to prove it.

View raw .md

Accessibility Checklist

What every shipped surface passes before merge, and how to prove it.

TL;DR (human)

A two-tier check: automated (axe in CI on every changed screen) + manual (screen-reader pass on UI-touching PRs). Keyboard-first is non-negotiable. Use shared primitives — they enshrine a11y so individual screens cannot break it. Tests that assert on structure / aria, not on rendered text.

For agents

Per-PR checklist (UI-touching)

Tick all before requesting review:

Keyboard

  • Every interactive element reachable by Tab.
  • Tab order matches visual order; no surprise jumps.
  • Focus is visible on every focusable element (token-based ring, not browser default).
  • Enter / Space activates buttons; Enter activates links.
  • Esc closes modals / popovers / dismissable surfaces.
  • Arrow keys navigate within composite widgets (tabs, listbox, radiogroup).
  • No keyboard trap (you can Tab into a region and Tab back out).

Semantics

  • Page has a meaningful \<title\> per route.
  • One \<h1\> per page; headings descend in order (no h1 → h3 skip).
  • Landmark elements present (\<header\>, \<main\>, \<nav\>, \<aside\>, \<footer\> — or aria landmark roles where appropriate).
  • Form fields have associated \<label\> (or aria-labelledby / aria-label).
  • Buttons have accessible names (text content or aria-label).
  • Icons-only buttons have aria-label.

Live regions

  • Toast / notification updates use aria-live="polite" (or assertive for blocking).
  • Loading-completed announces (e.g. "12 results loaded").
  • Error messages on form fields linked via aria-describedby.

Color / contrast

  • Text passes WCAG AA contrast against background (4.5:1 for body, 3:1 for large).
  • Information is not conveyed by color alone (icon + label, not just red text).
  • Focus ring contrasts against both light and dark surfaces.

Motion

  • prefers-reduced-motion: reduce short-circuits translates and rotations.
  • Opacity / instant transitions remain (per universal.md Rule 6).
  • No autoplaying video / audio with sound.

Images

  • Every \<img\> has alt (empty alt="" for decorative).
  • Icons inside \<button\> are aria-hidden="true" when there is a visible label.
  • SVG icons have \<title\> or aria-label when standalone.

Forms

  • Required fields marked (aria-required, visual asterisk + legend).
  • Validation errors announced (aria-invalid, message linked via aria-describedby).
  • Submit button is disabled OR shows clear loading state during submission.
  • Error summary at form top for long forms.

Modals / Dialogs

  • Focus traps inside the dialog while open.
  • Focus restores to the trigger when closed.
  • role="dialog" + aria-labelledby referencing the title.
  • Esc closes the dialog.
  • Background scroll locked while open.

Automated coverage

@axe-core (or your axe-equivalent) runs on every changed screen in CI. Findings of severity "serious" or "critical" fail the build. "minor" / "moderate" appear in PR comments for triage.

Sample integration with Playwright:

import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("/flows screen passes a11y scan", async ({ page }) => {
  await page.goto("/flows");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa"])
    .analyze();
  expect(results.violations).toEqual([]);
});

Manual screen-reader pass

Required for UI-touching PRs. Use one screen reader (per the team's primary platform):

  • VoiceOver on macOS (Cmd+F5 to toggle).
  • NVDA on Windows.
  • TalkBack on Android.

Pass:

  1. Navigate the page top-to-bottom with the screen reader.
  2. Confirm: page title makes sense.
  3. Confirm: each interactive element announces its purpose ("Save button", not "button button").
  4. Confirm: forms announce labels and errors.
  5. Confirm: navigation lands you on \<main\> quickly (skip-to-content link).

Document the pass in the PR description: "Screen reader: VoiceOver, 2026-MM-DD, all interactives announced cleanly."

Tests assert on a11y, not on rendered text

// ✗ wrong — breaks on intl / copy change
expect(screen.getByText("Save")).toBeInTheDocument();

// ✓ right — survives copy changes
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();

Using byRole + accessible name forces the test to verify the a11y attribute exists. A button without a name fails the test.

Common failure modes

  • tabIndex={-1} on every focusable element to "fix" tab order. Now no element is reachable. → Fix the source order, not the tabindex.
  • Custom \<div\> with onClick. No keyboard handler; not reachable. → Use the \<Button\> primitive.
  • Aria attributes copied without understanding. aria-label="button" is worse than no label. → Aria says what the element is, not its visual type.
  • Tooltip as the only label for an icon button. Screen readers may not announce tooltips. → aria-label on the button itself.
  • aria-hidden="true" on an interactive element. Hidden from screen readers but reachable by tab; very confusing. → inert, or actually remove from the tab order.
  • Color-only status indicators. Color-blind users miss them. → Color + icon + text.

Adoption path

  1. Ship axe in CI on every page; allow existing violations (baseline).
  2. Lock to shrink-only.
  3. New PRs cannot add violations.
  4. Sweep baseline screen-by-screen.

See also