accessibility-testing · diff
v1.0 to v2.0
184 added, 382 removed. Audit A to A.
---
name: accessibility-testing
description: >-
- Test for WCAG 2.2 compliance using axe-core with Playwright, keyboard navigation
- audits, screen reader testing, ARIA pattern validation, and legal compliance mapping
- (ADA, EAA, Section 508). Automated tools catch 30-40% of issues -- this skill covers
- both automated and manual testing strategies. Use when: "accessibility," "a11y,"
- "WCAG," "screen reader," "axe," "keyboard navigation," "ARIA," "ADA compliance."
- Related: playwright-automation, compliance-testing, cross-browser-testing.
+ Test for WCAG 2.2 AA compliance with axe-core + Playwright, keyboard navigation audits,
+ screen reader testing, ARIA pattern validation, and legal compliance mapping (ADA, EAA,
+ Section 508). Automated tools catch 30-40% of issues — this skill covers automated and
+ manual testing together. Use when: "accessibility," "a11y," "WCAG," "screen reader," "axe,"
+ "keyboard navigation," "ARIA," "ADA compliance."
+ Not for: cookie-consent/GDPR compliance — use compliance-testing; pixel-diff visual
+ regression — use visual-testing.
+ Related: playwright-automation, compliance-testing, visual-testing, ci-cd-integration.
license: MIT
metadata:
author: kindlmann
- version: "1.0"
- category: automation
+ version: "2.0"
+ category: specialized
---
<objective>
- Test applications for accessibility to ensure they are usable by everyone, including people who use assistive technologies. Automated tools catch 30-40% of accessibility issues -- the rest requires manual testing with keyboard navigation, screen readers, and human judgment. This skill covers both approaches.
+ Make an application usable by people who rely on keyboards and assistive technology, and prove
+ it with tests that run in CI. A button that passes `toBeVisible()` can still be unreachable by
+ keyboard; a page with zero axe violations can still be impossible to operate with a screen
+ reader. Automated tools catch 30-40% of accessibility issues — this skill covers the automated
+ scan plus the keyboard, screen reader, and ARIA-state testing that catch the other 60-70%.
</objective>
- ---
-
## Discovery Questions
- Before designing an accessibility testing strategy, understand the requirements and current state. Check `.agents/qa-project-context.md` first -- if it exists, use it as the foundation and skip questions already answered there.
-
- ### Requirements and Compliance
- - What WCAG conformance level is required? (A, AA, or AAA)
- - What legal requirements apply? (ADA, EAA/EN 301 549, Section 508, AODA)
- - Are there contractual accessibility requirements from customers? (common in enterprise/government)
- - Is there a Voluntary Product Accessibility Template (VPAT) to maintain?
+ Check `.agents/qa-project-context.md` first — if it exists, use it as the foundation and skip
+ anything already answered.
- ### Current State
- - Has an accessibility audit been performed before? What were the findings?
- - Are there known accessibility issues in the backlog?
- - Does the design system include accessibility guidelines?
+ **Requirements and compliance** (sets the target level and audit obligations)
+ - What WCAG conformance level is required — A, AA, or AAA? AA is the practical legal default.
+ - What laws apply — ADA, EAA/EN 301 549, Section 508, AODA? Each maps to a WCAG level.
+ - Is there a VPAT or accessibility statement to maintain, or contractual a11y clauses from enterprise/government customers?
- ### Testing Infrastructure
- - Is automated accessibility testing integrated into CI?
- - Which screen readers does the team test with? (VoiceOver, NVDA, JAWS, TalkBack)
- - What browsers and devices must be accessible?
+ **Current state** (tells you whether you're auditing or preventing regressions)
+ - Has an audit run before? What were the findings, and what's already in the backlog?
+ - Does the design system carry accessibility guidance and accessible components?
- ---
+ **Testing infrastructure** (determines what you can automate vs. must do by hand)
+ - Is automated a11y testing already in CI?
+ - Which screen readers does the team test with — VoiceOver, NVDA, JAWS, TalkBack?
## Core Principles
- ### 1. Automated Testing Catches 30-40% of Issues
- Tools like axe-core can detect missing alt text, insufficient color contrast, missing form labels, and invalid ARIA attributes. They cannot detect whether alt text is meaningful, whether the tab order is logical, whether a custom widget is operable by keyboard, or whether the reading order makes sense. Both automated and manual testing are essential.
-
- ### 2. Semantic HTML First, ARIA as Last Resort
- Native HTML elements (`<button>`, `<nav>`, `<input>`, `<table>`, `<dialog>`) carry built-in accessibility semantics, keyboard behavior, and screen reader support. Adding `role="button"` to a `<div>` requires also adding `tabindex`, `keydown` handlers for Enter and Space, focus styles, and ARIA states. Use the native element. Reach for ARIA only when no native element exists for the pattern.
-
- ### 3. Test in Order: Keyboard, Screen Reader, Automated
- Keyboard testing catches the most impactful issues (users physically blocked from features). Screen reader testing catches semantics issues (users confused by incorrect announcements). Automated testing catches the remaining mechanical issues (missing attributes, contrast ratios). Start with the highest-impact method.
-
- ### 4. Accessibility Is Not a Feature -- It Is a Quality Attribute
- Accessibility is tested continuously, like performance or security. Every new component, every new page, every PR gets accessibility review. Retrofitting accessibility onto a finished product costs 10-100x more than building it in from the start.
-
- ### 5. Test With Real Assistive Technology
- Browser DevTools and axe-core extensions are useful for development, but they are not substitutes for testing with actual screen readers. VoiceOver on macOS, NVDA on Windows, and TalkBack on Android each have different behaviors and quirks.
-
- ---
-
- ## Automated Testing with axe-core and Playwright
-
- ### Setup
-
- ```bash
- # axe-core 4.11.x or later — current is 4.11.4 (Apr 2026)
- npm install --save-dev @axe-core/playwright
- ```
-
- > **axe-core 4.11+ caveat:** Several best-practice rules (`focus-order-semantics`, `region`, `skip-link`, `table-duplicate-name`) are now also tagged `RGAAv4` (the French national standard). If you filter by tag and don't intend to test against RGAA, exclude it explicitly: `withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).disableRules([])` is fine, but `.withTags(['best-practice'])` will pull in RGAA-specific rules.
-
- ### Reusable Helper
-
- ```typescript
- // e2e/helpers/a11y.ts
- import { type Page, type TestInfo, expect } from '@playwright/test';
- import AxeBuilder from '@axe-core/playwright';
-
- interface A11yOptions {
- tags?: string[];
- exclude?: string[];
- disableRules?: string[];
- }
-
- export async function checkAccessibility(
- page: Page, testInfo: TestInfo, options: A11yOptions = {}
- ): Promise<void> {
- let builder = new AxeBuilder({ page })
- .withTags(options.tags ?? ['wcag2a', 'wcag2aa', 'wcag22aa']);
-
- for (const sel of options.exclude ?? []) builder = builder.exclude(sel);
- if (options.disableRules?.length) builder = builder.disableRules(options.disableRules);
-
- const results = await builder.analyze();
-
- await testInfo.attach('a11y-results', {
- body: JSON.stringify(results, null, 2), contentType: 'application/json',
- });
-
- const violations = results.violations.map((v) => ({
- rule: v.id, impact: v.impact, description: v.description,
- helpUrl: v.helpUrl, elements: v.nodes.map((n) => n.html).slice(0, 5),
- }));
-
- expect(violations, `${violations.length} a11y violations:\n${JSON.stringify(violations, null, 2)}`)
- .toHaveLength(0);
- }
- ```
-
- ### Using in Tests
-
- ```typescript
- // e2e/tests/a11y/pages.spec.ts
- import { test, expect } from '@playwright/test';
- import { checkAccessibility } from '../../helpers/a11y';
+ 1. **Automated testing catches 30-40% of issues — no more.** axe-core finds missing alt text,
+ low contrast, missing labels, and invalid ARIA. It cannot tell you whether alt text is
+ meaningful, whether tab order is logical, or whether a custom widget is operable. Passing
+ axe is necessary, not sufficient. Crucially, axe ships **no automated rule** for several
+ WCAG 2.2 success criteria (2.4.11 focus-not-obscured, 2.5.7 dragging movements, and 2.5.8
+ target-size only partially) — a green axe run does not equal 2.2 AA conformance.
- test.describe('Accessibility - public pages', () => {
- for (const { name, path } of [
- { name: 'Home', path: '/' }, { name: 'Login', path: '/login' },
- { name: 'Pricing', path: '/pricing' }, { name: 'Sign Up', path: '/signup' },
- ]) {
- test(`${name} page has no a11y violations`, async ({ page }, testInfo) => {
- await page.goto(path);
- await checkAccessibility(page, testInfo);
- });
- }
- });
+ 2. **Semantic HTML first, ARIA as last resort.** Native elements (`<button>`, `<nav>`,
+ `<input>`, `<dialog>`) carry built-in semantics, keyboard behavior, and screen reader
+ support. A `<div role="button">` also needs `tabindex`, Enter/Space handlers, focus styles,
+ and ARIA state — all of which a `<button>` gives you free. Reach for ARIA only when no
+ native element fits.
- test.describe('Accessibility - interactive states', () => {
- test('modal dialog is accessible when open', async ({ page }, testInfo) => {
- await page.goto('/dashboard');
- await page.getByRole('button', { name: 'Create project' }).click();
- await expect(page.getByRole('dialog')).toBeVisible();
- await checkAccessibility(page, testInfo);
- });
- });
- ```
+ 3. **Test in impact order: keyboard, screen reader, automated.** Keyboard issues physically
+ block users from features (highest impact). Screen reader issues confuse with wrong
+ announcements. Automated checks catch the mechanical remainder. Start where the damage is
+ worst, not where the tooling is easiest.
- ### Rule Suppression
+ 4. **Accessibility is a quality attribute, not a feature.** Test it continuously like
+ performance or security — every new component, every PR. Retrofitting accessibility onto a
+ finished product costs 10-100x more because inaccessible patterns get baked into the
+ component library.
- Suppress rules only with documented justification:
+ 5. **Test with real assistive technology.** Browser DevTools and axe extensions are dev aids,
+ not substitutes for VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android), which
+ each behave differently. Reserve manual AT passes for complex custom widgets.
- ```typescript
- await checkAccessibility(page, testInfo, {
- disableRules: ['frame-title'], // Third-party chat widget; tracked in PROJ-4521
- exclude: ['#third-party-analytics-widget'],
- });
- ```
+ ## Automated Scanning with axe-core + Playwright
- ### CI Integration
+ Install `@axe-core/playwright` (the 4.11.x line; it tracks axe-core's major.minor). Wrap it in
+ a reusable `checkAccessibility(page, testInfo, options)` helper that filters to the WCAG tags
+ `['wcag2a', 'wcag2aa', 'wcag22aa']`, attaches the full results JSON to the test for the audit
+ trail, and asserts `violations.toHaveLength(0)`. Loop it over every key page and over
+ interactive states (modal open, menu expanded), not just the default load.
- ```yaml
- # .github/workflows/a11y.yml
- name: Accessibility Tests
- on:
- push: { branches: [main] }
- pull_request: { branches: [main] }
- jobs:
- a11y:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with: { node-version: 20, cache: npm }
- - run: npm ci
- - run: npx playwright install --with-deps chromium
- - run: npm run build && npm start &
- - run: npx wait-on http://localhost:3000 --timeout 60000
- - run: npx playwright test e2e/tests/a11y/
- - uses: actions/upload-artifact@v4
- if: ${{ !cancelled() }}
- with: { name: a11y-report, path: 'test-results/\nplaywright-report/', retention-days: 14 }
- ```
+ Suppress a rule only with a documented justification (a tracking issue or inline comment) and
+ `exclude` third-party widgets you don't own rather than disabling the rule globally.
- ---
+ See `references/recipes.md` for the install command, RGAA tag caveat, the full helper, the
+ page-loop and interactive-state specs, rule suppression, and CI integration.
## Manual Testing Checklist
- ### Keyboard Navigation Audit
-
- - [ ] **Tab order is logical:** Left-to-right, top-to-bottom for LTR languages. No unexpected focus jumps.
- - [ ] **All interactive elements are reachable** via Tab/Shift+Tab.
- - [ ] **Focus indicator is visible** on every focused element. No `outline: none` without a replacement.
- - [ ] **Skip link works:** First Tab reveals "Skip to main content" link.
- - [ ] **Enter activates** buttons and links. **Space activates** buttons and toggles checkboxes.
- - [ ] **Escape closes** modals, dropdowns, tooltips. Focus returns to trigger element.
- - [ ] **Arrow keys** navigate within tab panels, menus, radio groups, and tree views.
- - [ ] **No keyboard traps** (exception: modal dialogs intentionally trap focus until dismissed).
- - [ ] **Custom widgets are operable** without a mouse (sliders, date pickers, drag-and-drop).
-
- ```typescript
- // e2e/tests/a11y/keyboard.spec.ts
- import { test, expect } from '@playwright/test';
-
- test('skip link moves focus to main content', async ({ page }) => {
- await page.goto('/');
- await page.keyboard.press('Tab');
- const skipLink = page.getByRole('link', { name: /skip to (main )?content/i });
- await expect(skipLink).toBeFocused();
- await page.keyboard.press('Enter');
- await expect(page.getByRole('main')).toBeFocused();
- });
-
- test('modal traps focus and returns it on close', async ({ page }) => {
- await page.goto('/dashboard');
- const trigger = page.getByRole('button', { name: 'Create project' });
- await trigger.click();
- const dialog = page.getByRole('dialog');
- await expect(dialog).toBeVisible();
+ Automated scanning is the floor. These checks need a human (or a keyboard-driven Playwright
+ spec — see `references/recipes.md` for the keyboard specs).
- // Escape closes and returns focus to trigger
- await page.keyboard.press('Escape');
- await expect(dialog).toBeHidden();
- await expect(trigger).toBeFocused();
- });
+ ### Keyboard navigation audit
- test('form can be completed entirely by keyboard', async ({ page }) => {
- await page.goto('/signup');
- await page.keyboard.press('Tab');
- await page.keyboard.type('Jane Doe');
- await page.keyboard.press('Tab');
- await page.keyboard.type('jane@example.com');
- await page.keyboard.press('Tab');
- await page.keyboard.type('SecureP@ss123');
- await page.keyboard.press('Tab');
- await page.keyboard.press('Space'); // Toggle checkbox
- await expect(page.getByRole('checkbox', { name: /terms/i })).toBeChecked();
- await page.keyboard.press('Tab');
- await page.keyboard.press('Enter'); // Submit
- await expect(page).toHaveURL(/\/welcome/);
- });
- ```
+ - [ ] **Tab order is logical** — left-to-right, top-to-bottom for LTR. No surprise focus jumps.
+ - [ ] **All interactive elements reachable** via Tab / Shift+Tab.
+ - [ ] **Focus indicator visible** on every focused element. No `outline: none` without a replacement.
+ - [ ] **Skip link works** — first Tab reveals "Skip to main content"; Enter moves focus to `<main>`.
+ - [ ] **Enter activates** buttons/links; **Space activates** buttons and toggles checkboxes.
+ - [ ] **Escape closes** modals, dropdowns, tooltips; focus returns to the trigger.
+ - [ ] **Arrow keys** navigate within tabs, menus, radio groups, tree views.
+ - [ ] **No keyboard traps** (modal dialogs intentionally trap until dismissed — that's allowed).
+ - [ ] **Custom widgets operable** without a mouse (sliders, date pickers, drag-and-drop).
- ### Screen Reader Testing
+ ### Screen reader testing
| Screen Reader | OS | Browser | Free? |
|--------------|-----|---------|-------|
| VoiceOver | macOS/iOS | Safari | Yes (Cmd+F5) |
| NVDA | Windows | Firefox/Chrome | Yes |
| JAWS | Windows | Chrome/Edge | No |
| TalkBack | Android | Chrome | Yes |
- **Checklist:**
- - [ ] Page title announced on navigation
- - [ ] Headings create a navigable outline (h1 -> h2 -> h3, no skipped levels)
- - [ ] Images have descriptive alt text (or `alt=""` for decorative images)
- - [ ] Form inputs announce their labels when focused
- - [ ] Required fields announced as required; errors associated with inputs
- - [ ] Live regions announce dynamic content (toasts, loading states)
- - [ ] Buttons and links announce their purpose (no "click here")
+ - [ ] Page title announced on navigation.
+ - [ ] Headings form a navigable outline (h1 → h2 → h3, no skipped levels).
+ - [ ] Images have descriptive alt text (or `alt=""` for decorative).
+ - [ ] Form inputs announce their labels when focused.
+ - [ ] Required fields announced as required; errors associated with their input.
+ - [ ] Live regions announce dynamic content (toasts, loading states).
+ - [ ] Buttons/links announce their purpose (no "click here").
- ### Color Contrast and Visual
- - [ ] Normal text: 4.5:1 contrast ratio minimum (WCAG AA)
- - [ ] Large text (18pt+ or 14pt+ bold): 3:1 minimum
- - [ ] UI components: 3:1 against adjacent colors
- - [ ] Information not conveyed by color alone (add icons, patterns, or text)
+ ### Color contrast and visual
- ### Form and Error Accessibility
- - [ ] Every input has a visible `<label>` associated via `for`/`id`
- - [ ] Required fields indicated visually and programmatically (`required` or `aria-required`)
- - [ ] Errors use `aria-describedby` to associate with inputs and `role="alert"` for announcement
- - [ ] Focus moves to first error on form submission failure
- - [ ] Form groups use `<fieldset>` and `<legend>`
+ - [ ] Normal text: **4.5:1** minimum (WCAG AA). Large text (18pt+ / 14pt+ bold): **3:1**.
+ - [ ] UI components and graphical objects: **3:1** against adjacent colors.
+ - [ ] Information never conveyed by color alone — add icons, patterns, or text.
- ---
+ ### Form and error accessibility
+ - [ ] Every input has a visible `<label>` tied via `for`/`id` (placeholder is not a label).
+ - [ ] Required fields indicated visually **and** programmatically (`required` / `aria-required`).
+ - [ ] Errors use `aria-describedby` to link to the input and `role="alert"` to announce.
+ - [ ] Focus moves to the first error on submission failure.
+ - [ ] Related fields grouped with `<fieldset>` and `<legend>`.
+
## WCAG 2.2 Quick Reference
- ### Level A (Must Fix)
+ ### Level A (must fix)
- | Criterion | What It Means | Common Failure |
+ | Criterion | What it means | Common failure |
|-----------|--------------|---------------|
- | 1.1.1 Non-text Content | Images have alt text | `<img>` without `alt` attribute |
- | 1.3.1 Info and Relationships | Structure via HTML semantics | `<div>` styled as heading instead of `<h2>` |
- | 2.1.1 Keyboard | All functionality via keyboard | Custom widget only responds to mouse |
+ | 1.1.1 Non-text Content | Images have alt text | `<img>` without `alt` |
+ | 1.3.1 Info and Relationships | Structure via HTML semantics | `<div>` styled as a heading |
+ | 2.1.1 Keyboard | All functionality via keyboard | Custom widget responds only to mouse |
| 2.4.1 Bypass Blocks | Skip navigation link | No skip link |
- | 3.1.1 Language of Page | `<html lang="en">` set | Missing `lang` attribute |
- | 3.3.1 Error Identification | Errors described in text | Error indicated only by red border |
+ | 3.1.1 Language of Page | `<html lang="en">` set | Missing `lang` |
+ | 3.3.1 Error Identification | Errors described in text | Error shown only by red border |
| 4.1.2 Name, Role, Value | Custom controls expose name/role | `<div onclick>` with no role |
- ### Level AA (Most Common Legal Requirement)
+ ### Level AA (most common legal requirement)
- | Criterion | What It Means | Common Failure |
+ | Criterion | What it means | Common failure |
|-----------|--------------|---------------|
- | 1.4.3 Contrast (Minimum) | 4.5:1 normal text, 3:1 large | Light gray on white |
+ | 1.4.3 Contrast (Minimum) | 4.5:1 normal, 3:1 large | Light gray on white |
| 1.4.4 Resize Text | Scales to 200% without loss | Fixed-height containers clip text |
- | 1.4.11 Non-text Contrast | UI components 3:1 ratio | Low-contrast input borders |
- | 2.4.7 Focus Visible | Keyboard focus visible | `outline: none` without replacement |
- | 2.5.8 Target Size | Touch targets 24x24px minimum | Tiny icon buttons |
- | 3.3.2 Labels or Instructions | Inputs have labels | Placeholder as only label |
+ | 1.4.11 Non-text Contrast | UI components 3:1 | Low-contrast input borders |
+ | 2.4.7 Focus Visible | Keyboard focus visible | `outline: none` with no replacement |
+ | 2.5.8 Target Size | Touch targets 24×24px min | Tiny icon buttons |
+ | 3.3.2 Labels or Instructions | Inputs have labels | Placeholder as the only label |
| 3.3.8 Accessible Auth | No cognitive function test | CAPTCHA with no alternative |
- ### Level AAA (Nice to Have)
+ ### Level AAA (nice to have)
- | Criterion | What It Means |
+ | Criterion | What it means |
|-----------|--------------|
| 1.4.6 Contrast (Enhanced) | 7:1 normal text, 4.5:1 large |
| 2.4.9 Link Purpose (Link Only) | Link text alone describes destination |
- | 3.1.5 Reading Level | Lower secondary education level |
-
- ---
-
- ## Accessible Patterns with Code
-
- ### Accessible Forms
-
- ```typescript
- test('form displays accessible error messages', async ({ page }) => {
- await page.goto('/contact');
- await page.getByRole('button', { name: 'Send message' }).click();
-
- const emailInput = page.getByLabel('Email address');
- const emailError = page.getByText('Email is required');
- await expect(emailError).toBeVisible();
-
- // Verify aria-describedby links input to error
- const errorId = await emailError.getAttribute('id');
- expect(await emailInput.getAttribute('aria-describedby')).toContain(errorId);
- await expect(emailInput).toHaveAttribute('aria-invalid', 'true');
- await expect(emailInput).toBeFocused();
- });
- ```
-
- Expected HTML: `<label for="email">Email</label>` + `<input id="email" aria-required="true" aria-invalid="true" aria-describedby="email-error">` + `<p id="email-error" role="alert">Email is required</p>`
-
- ### Modal/Dialog Accessibility
-
- ```typescript
- test('dialog follows ARIA dialog pattern', async ({ page }) => {
- await page.goto('/dashboard');
- const trigger = page.getByRole('button', { name: 'Delete project' });
- await trigger.click();
-
- const dialog = page.getByRole('dialog');
- await expect(dialog).toBeVisible();
- await expect(dialog).toHaveAttribute('aria-labelledby');
- await expect(dialog).toHaveAttribute('aria-modal', 'true');
- await expect(dialog.locator(':focus')).toBeVisible(); // Focus inside dialog
-
- await page.keyboard.press('Escape');
- await expect(dialog).toBeHidden();
- await expect(trigger).toBeFocused(); // Focus returns to trigger
- });
- ```
-
- ### Dynamic Content -- Live Regions
-
- ```typescript
- test('toast notifications are announced', async ({ page }) => {
- await page.goto('/settings');
- const toastRegion = page.locator('[aria-live="polite"]');
- await expect(toastRegion).toBeAttached();
-
- await page.getByRole('button', { name: 'Save changes' }).click();
- await expect(toastRegion.getByText('Settings saved')).toBeVisible();
- });
- ```
-
- ### Data Tables
-
- ```typescript
- test('table has proper headers and sort state', async ({ page }) => {
- await page.goto('/users');
- const table = page.getByRole('table', { name: 'User accounts' });
- await expect(table.getByRole('columnheader')).toHaveCount(5);
-
- const nameHeader = page.getByRole('columnheader', { name: 'Name' });
- await nameHeader.click();
- await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
- });
- ```
-
- ### Navigation Landmarks
-
- ```typescript
- test('page has required landmarks', async ({ page }) => {
- await page.goto('/dashboard');
- await expect(page.getByRole('banner')).toBeVisible(); // <header>
- await expect(page.getByRole('navigation')).toBeVisible(); // <nav>
- await expect(page.getByRole('main')).toBeVisible(); // <main>
- await expect(page.getByRole('main')).toHaveCount(1); // Exactly one <main>
- await expect(page.getByRole('contentinfo')).toBeVisible(); // <footer>
- });
- ```
+ | 3.1.5 Reading Level | Lower-secondary education level |
- ---
+ ## Accessible Patterns
- ## ARIA Snapshots (Playwright)
+ Test on the accessible tree (roles, names, ARIA state), not on CSS. The patterns you need
+ runnable tests for:
- Playwright's `toMatchAriaSnapshot()` captures the accessible tree structure and asserts against it. Useful for catching regressions where visual changes break semantics.
+ - **Forms** — error linked via `aria-describedby`, `aria-invalid='true'`, focus on first error.
+ - **Modal/dialog** — `aria-modal='true'`, `aria-labelledby`, focus trapped, Escape returns focus.
+ - **Interactive states** — opened dropdown (`role="menu"` or `role="listbox"`, `aria-expanded`),
+ loading skeleton (`aria-busy='true'` during fetch), toast (`aria-live='polite'`). These carry
+ different ARIA per state, so click to trigger the state change and assert the open-state ARIA —
+ the default page snapshot never exercises them.
+ - **Data tables** — `columnheader` roles, `aria-sort` reflects the active sort.
+ - **Landmarks** — exactly one `main`; `banner`, `navigation`, `contentinfo` present.
- ```typescript
- test('navigation has correct accessible structure', async ({ page }) => {
- await page.goto('/dashboard');
- await expect(page.getByRole('navigation', { name: 'Main' })).toMatchAriaSnapshot(`
- - navigation "Main":
- - link "Dashboard"
- - link "Projects"
- - link "Settings"
- - link "Help"
- `);
- });
+ See `references/patterns.md` for the full runnable tests for every pattern above.
- test('form has correct accessible structure', async ({ page }) => {
- await page.goto('/settings');
- await expect(page.getByRole('form', { name: 'Profile settings' })).toMatchAriaSnapshot(`
- - form "Profile settings":
- - textbox "Display name"
- - textbox "Email address"
- - combobox "Time zone"
- - checkbox "Email notifications"
- - button "Save changes"
- `);
- });
- ```
+ ## ARIA Snapshots
- ---
+ Playwright's `toMatchAriaSnapshot()` captures the accessible tree as YAML and asserts against
+ it — the fastest way to catch a regression where a visual change silently breaks semantics (a
+ `<div>` restyled to look like a button, a heading demoted to plain text). It checks structure
+ and accessible names, not pixels, so it's complementary to `visual-testing`, not a replacement.
+ Scope snapshots to a stable container; whole-page snapshots over async content go flaky. See
+ `references/patterns.md` for navigation and form snapshot examples.
## Legal Compliance Mapping
- | Law / Standard | Region | WCAG Level Required | Enforcement |
+ | Law / Standard | Region | WCAG level required | Enforcement |
|---------------|--------|-------------------|------------|
| **ADA** | USA | AA (court precedent) | Lawsuits (private right of action) |
| **Section 508** | USA (federal) | WCAG 2.0 AA | Federal procurement requirement |
- | **EAA** | EU | EN 301 549 (WCAG 2.1 AA) | **In force since 28 June 2025.** Member states actively enforcing; private cause of action varies by country (DE, FR, IE among the most active). EN 301 549 is expected to align with WCAG 2.2 in its next revision. |
+ | **EAA** | EU | EN 301 549 (WCAG 2.1 AA) | **In force since 28 June 2025.** Member states actively enforcing; private cause of action varies (DE, FR, IE most active). EN 301 549 expected to align with WCAG 2.2 next revision. |
| **AODA** | Ontario, Canada | WCAG 2.0 AA | Fines up to $100K/day |
| **EN 301 549** | EU | WCAG 2.1 AA | Public procurement requirement |
| **Equality Act 2010** | UK | WCAG 2.1 AA (guidance) | Lawsuits |
| **ISO/IEC 40500:2025** | International | Equivalent to WCAG 2.2 (Oct 2023) | Useful for procurement/RFP language; freely available from ISO |
- **Key takeaway:** If your product serves users in the US or EU, **WCAG 2.2 AA is the practical target for new development** — the EAA is in force, EN 301 549 is expected to update to 2.2, and ISO/IEC 40500:2025 codifies WCAG 2.2 as an international standard. WCAG 2.1 AA is the legacy minimum where 2.2 cannot be reached immediately.
-
- **WCAG 3 status:** W3C published updated WCAG 3 drafts in March 2026 (renamed "Foundational Requirements" → "Core Requirements," "Outcomes" → "Requirements," added a Best Practices section). Still a working draft; a final standard is years away. Plan for WCAG 2.2 today; track WCAG 3 but do not test against it yet.
+ **Practical target:** if you serve US or EU users, **WCAG 2.2 AA is the target for new
+ development** — the EAA is in force, EN 301 549 is expected to update to 2.2, and ISO/IEC
+ 40500:2025 (published Sept 2025) codifies WCAG 2.2 internationally. WCAG 2.1 AA is the legacy
+ minimum where 2.2 can't be reached immediately.
- **Evidence to collect for audits:** Automated scan results per page, manual testing checklists with tester/date, screen reader test results with AT versions, accessibility statement, VPAT for enterprise sales, remediation plan for known issues.
+ **WCAG 3 status:** W3C published an updated WCAG 3 working draft in March 2026 that renamed
+ "Outcomes" to "Requirements" and moved away from binary pass/fail grading; it lists ~174
+ requirements. It remains a working draft — Candidate Recommendation is targeted for Q4 2027 and
+ a Recommendation not before 2028. Plan for WCAG 2.2 today; track WCAG 3 but do not test against
+ it yet.
- ---
+ **Audit evidence to collect:** automated scan results per page, manual checklists with
+ tester/date, screen reader results with AT versions, accessibility statement, VPAT for
+ enterprise sales, and a remediation plan for known issues. For the full legal/VPAT/consent
+ mapping, see `compliance-testing`.
## Anti-Patterns
- ### Only Automated Testing
- Running axe-core and declaring the product accessible because it found zero violations. Automated tools miss 60-70% of real accessibility issues. Keyboard testing, screen reader testing, and cognitive review are essential complements.
+ ### Only automated testing
+ Running axe, finding zero violations, and declaring the product accessible. Automated tools
+ miss 60-70% of real issues and skip several WCAG 2.2 criteria entirely. **Fix:** pair every axe
+ run with the keyboard and screen reader checklist; gate releases on both, not just the scan.
- ### ARIA Overuse
- Adding `role`, `aria-label`, and `aria-describedby` to elements that already have native semantics. A `<button>` does not need `role="button"`. Extra ARIA can create confusing double announcements in screen readers.
+ ### ARIA overuse
+ Adding `role`, `aria-label`, and `aria-describedby` to elements that already have native
+ semantics, creating double announcements. **Fix:** delete the redundant ARIA and use the native
+ element — a `<button>` never needs `role="button"`.
- ### Ignoring Keyboard Users
- Building features that work with mouse and touch but not keyboard. Custom dropdowns that only open on click, drag-and-drop with no keyboard alternative, and hover-only tooltips all block keyboard users. Every mouse interaction needs a keyboard equivalent.
+ ### Ignoring keyboard users
+ Features that work by mouse and touch but not keyboard — click-only dropdowns, drag-and-drop
+ with no keyboard path, hover-only tooltips. **Fix:** give every mouse interaction a keyboard
+ equivalent and cover it with a `keyboard.spec.ts` test (see `references/recipes.md`).
- ### Retrofitting Accessibility
- Waiting until the product is "finished" to add accessibility. By then, inaccessible patterns are baked into the component library and the cost to fix is 10-100x higher. Test accessibility from the first component.
+ ### Retrofitting accessibility
+ Waiting until the product is "finished," by which point inaccessible patterns are baked into the
+ component library at 10-100x the fix cost. **Fix:** add an axe check to the Definition of Done
+ so every new component is gated on accessibility before merge.
- ### Treating Accessibility as Optional
- Deprioritizing accessibility tickets because "nobody has complained." Users with disabilities often cannot complain through the product if it is inaccessible. They leave silently. Accessibility lawsuits in the US have increased year over year since 2018.
+ ### Treating accessibility as optional
+ Deprioritizing a11y tickets because "nobody complained" — users with disabilities can't complain
+ through a product they can't use, so they leave silently. **Fix:** track a11y as a release
+ blocker with the same severity rules as functional bugs, and report open a11y issues in the
+ release readiness check.
- ### Testing Only the Happy Path
- Running accessibility checks only on the default page state. Interactive states (modals open, dropdowns expanded, error messages displayed, loading skeletons, empty states) all need accessibility testing. Components often have different ARIA attributes in different states.
+ ### Testing only the happy path
+ Scanning only the default page state, missing the modals, expanded dropdowns, error messages,
+ and loading skeletons that carry different ARIA. **Fix:** drive each interactive state in the
+ test (click to open the menu, trigger the fetch) and re-run the scan against the changed DOM.
- ---
+ ## Failure Modes
+ | Symptom | Likely cause | Fix or check |
+ |---------|-------------|-------------|
+ | axe finds 0 violations but the page is unusable by keyboard | Automated scans don't test operability or focus order | Run the keyboard audit; add a `keyboard.spec.ts` |
+ | `toMatchAriaSnapshot` is flaky | Dynamic content or list reordering inside the snapshot scope | Scope to a stable container; use a partial snapshot |
+ | Contrast rule passes but text over a gradient/overlay/image is unreadable | axe can't compute contrast against non-solid backgrounds | Check those cases manually or with a contrast picker |
+ | Passing axe but failing a WCAG 2.2 AA audit | axe ships no rule for 2.4.11 / 2.5.7 and only partial 2.5.8 | Manually verify focus-not-obscured, dragging alternatives, and target size |
+
## Done When
- - axe-core integrated into the E2E test suite and runs automatically on all key pages (home, login, checkout, dashboard, settings).
- - CI pipeline reports zero critical or serious axe violations and blocks merge when any are introduced.
+ - axe-core integrated into the E2E suite and run automatically on all key user-facing pages identified in the test strategy.
+ - CI reports zero critical or serious axe violations and blocks merge when any are introduced (see `references/recipes.md` for the workflow).
- Keyboard navigation tested end-to-end for all interactive flows (forms, modals, dropdowns, navigation menus).
- - Color contrast validated for the full brand palette against WCAG AA thresholds (4.5:1 for normal text, 3:1 for large text and UI components).
- - Screen reader testing notes documented for complex custom widgets (date pickers, data tables, drag-and-drop), including which screen reader and version was used.
+ - Interactive-state ARIA verified for at least one dropdown/menu (`aria-expanded` + `role`), one loading region (`aria-busy`), and one live region (`aria-live`).
+ - Color contrast validated for the full brand palette against WCAG AA thresholds (4.5:1 normal text, 3:1 large text and UI components).
+ - Screen reader test notes documented for complex custom widgets (date pickers, data tables, drag-and-drop), including which screen reader and version was used.
## Related Skills
- - **playwright-automation** -- Playwright is the test runner for both automated axe-core scans and keyboard/ARIA snapshot tests; this skill provides the accessibility-specific patterns.
- - **ci-cd-integration** -- Accessibility tests should run in CI and block merges when violations are found.
- - **risk-based-testing** -- Accessibility risk assessment helps prioritize which pages and components to audit first.
- - **test-strategy** -- The test strategy should include accessibility as a test type with defined coverage targets and ownership.
+ - **playwright-automation** — the test runner for both axe scans and keyboard/ARIA snapshot tests; this skill adds the accessibility-specific patterns on top.
+ - **compliance-testing** — legal/regulatory testing including cookie consent (GDPR/CMP), VPAT generation, and EAA/Section 508 reporting. Go there for consent banners and formal compliance documentation; stay here for WCAG conformance testing.
+ - **visual-testing** — pixel-diff screenshot regression. Use it for visual rendering changes; use this skill's ARIA snapshots for semantic-tree regressions and contrast for the a11y-specific color thresholds.
+ - **ci-cd-integration** — running a11y tests in CI and blocking merges on violations.
+ - **risk-based-testing** — prioritizes which pages and components to audit first.