Playwright Accessibility Testing (TypeScript)
Comprehensive toolkit for automated accessibility testing using Playwright with TypeScript and axe-core. Enables WCAG 2.2 Level AA compliance verification (superset of 2.1), keyboard operability testing, semantic validation, and accessibility regression prevention.
Activation: This skill is triggered when working with accessibility testing, WCAG compliance, axe-core scans, keyboard navigation tests, focus management, ARIA validation, or screen reader compatibility.
When to Use This Skill
- Automated a11y scans with axe-core for WCAG 2.2 AA compliance
- Keyboard navigation tests for Tab/Enter/Space/Escape/Arrow key operability
- Focus management validation for dialogs, menus, and dynamic content
- Semantic structure assertions for landmarks, headings, and ARIA
- Form accessibility testing for labels, errors, and instructions
- Color contrast and visual accessibility verification
- Screen reader compatibility testing patterns
Do NOT Use For
- Selenium/Java accessibility testing (use
accessibility-selenium-testing).
- Authoring Playwright functional/UI E2E specs (use
playwright-e2e-testing).
- Full conformance sign-off — automated axe scans catch ~30-40% of issues; manual audit + assistive-tech testing is still required.
Prerequisites
| Requirement |
Details |
| Node.js |
v18+ recommended |
| Playwright |
@playwright/test installed |
| axe-core |
@axe-core/playwright package |
| TypeScript |
Configured in project |
Quick Setup
# Add axe-core to existing Playwright project
npm install -D @axe-core/playwright axe-core
First Questions to Ask
Before writing accessibility tests, clarify:
- Scope: Which pages/flows are in scope? What's explicitly excluded?
- Standard: WCAG 2.2 AA (default) or specific organizational policy?
- Priority: Which components are highest risk (forms, modals, navigation, checkout)?
- Exceptions: Known constraints (legacy markup, third-party widgets)?
- Assistive Tech: Which screen readers/browsers need manual testing?
Core Principles
1. Automation Limitations
[!] Critical: Automated tooling can detect ~30-40% of accessibility issues. Use automation to prevent regressions and catch common failures; manual audits are required for full WCAG conformance.
2. Semantic HTML First
Prefer native HTML semantics over ARIA. Use ARIA only when native elements cannot achieve the required semantics.
// [ok] Semantic HTML - inherently accessible
await page.getByRole("button", { name: "Submit" }).click();
// [no] ARIA override - requires manual keyboard/focus handling
await page.locator('[role="button"]').click(); // Often a <div>
3. Locator Strategy as A11y Signal
If you cannot locate an element by role or label, it's often an accessibility defect.
| Locator Success |
Accessibility Signal |
getByRole('button', { name: 'Submit' }) [ok] |
Button has accessible name |
getByLabel('Email') [ok] |
Input properly labeled |
getByRole('navigation') [ok] |
Landmark exists |
locator('.submit-btn') [!] |
May lack accessible name |
Key Workflows
Automated Axe Scan (WCAG 2.2 AA)
import AxeBuilder from "@axe-core/playwright";
import { test, expect } from "@playwright/test";
test("page has no WCAG 2.2 AA violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
Scoped Axe Scan (Component-Level)
test("form component is accessible", async ({ page }) => {
await page.goto("/contact");
const results = await new AxeBuilder({ page })
.include("#contact-form") // Scope to specific component
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
Keyboard Navigation Test
test("form is keyboard navigable", async ({ page }) => {
await page.goto("/login");
// Tab to first field
await page.keyboard.press("Tab");
await expect(page.getByLabel("Email")).toBeFocused();
// Tab to password
await page.keyboard.press("Tab");
await expect(page.getByLabel("Password")).toBeFocused();
// Tab to submit button
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused();
// Submit with Enter
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/dashboard/);
});
Dialog Focus Management
test("dialog traps and returns focus", async ({ page }) => {
await page.goto("/settings");
const trigger = page.getByRole("button", { name: "Delete account" });
// Open dialog
await trigger.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Focus should be inside dialog
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();
// Tab should stay trapped in dialog
await page.keyboard.press("Tab");
await expect(dialog.getByRole("button", { name: "Confirm" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();
// Escape closes and returns focus to trigger
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
Skip Link Validation
test("skip link moves focus to main content", async ({ page }) => {
await page.goto("/");
// First Tab should focus skip link
await page.keyboard.press("Tab");
const skipLink = page.getByRole("link", { name: /skip to (main|content)/i });
await expect(skipLink).toBeFocused();
// Activating skip link moves focus to main
await page.keyboard.press("Enter");
await expect(page.locator('#main, [role="main"]').first()).toBeFocused();
});
POUR Principles Reference
| Principle |
Focus Areas |
Example Tests |
| Perceivable |
Alt text, captions, contrast, structure |
Image alternatives, color contrast ratio |
| Operable |
Keyboard, focus, timing, navigation |
Tab order, focus visibility, skip links |
| Understandable |
Labels, instructions, errors, consistency |
Form labels, error messages, predictable behavior |
| Robust |
Valid HTML, ARIA, name/role/value |
Semantic structure, accessible names |
Axe-Core Tags
Default: wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22a, wcag22aa (WCAG 2.2 AA). Use best-practice for additional checks. See references/axe-tags-reference.md for full tag list.
Exception Handling
When exceptions are unavoidable:
- Scope narrowly - specific component/route only
- Document impact - which WCAG criterion, user impact
- Set expiration - owner + remediation date
- Track ticket - link to remediation issue
// [no] Avoid: Global rule disable
new AxeBuilder({ page }).disableRules(["color-contrast"]);
// [ok] Better: Scoped exclusion with documentation
new AxeBuilder({ page })
.exclude("#third-party-widget") // Known issue: JIRA-1234, fix by Q2
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
.analyze();
Troubleshooting
| Problem |
Cause |
Solution |
| Axe finds 0 violations but app fails manual audit |
Automation covers ~30-40% |
Add manual testing checklist |
| False positive on dynamic content |
Content not fully rendered |
Wait for stable state before scan |
| Color contrast fails incorrectly |
Background image/gradient |
Use exclude for known false positives |
| Cannot find element by role |
Missing semantic HTML |
Fix markup - this is a real bug |
| Focus not visible |
Missing :focus styles |
Add visible focus indicator CSS |
| Dialog focus not trapped |
Missing focus trap logic |
Implement focus trap (see snippets) |
| Skip link doesn't work |
Target missing tabindex="-1" |
Add tabindex to main content |
CLI Quick Reference
| Command |
Description |
npx playwright test --grep "a11y" |
Run accessibility tests only |
npx playwright test --headed |
Run with visible browser for debugging |
npx playwright test --debug |
Step through with Inspector |
PWDEBUG=1 npx playwright test |
Debug mode with pause |
Red Flags
- Treating a clean axe scan as full WCAG conformance — automation covers only ~30-40% of criteria.
- Globally disabling rules (e.g.,
color-contrast) instead of scoped .exclude() with a documented ticket.
- Scanning before the page reaches a stable state — async content yields false "0 violations".
- Skipping keyboard/focus tests because axe passed — focus order and traps need explicit tests.
References
| Document |
Content |
| Snippets: Setup & Scanning |
axe-core setup, helper, and scanning patterns |
| Snippets: Keyboard, Focus, Semantic |
Keyboard navigation, focus management, semantic structure |
| Snippets: Visual, Names, Checklist |
Visual accessibility, accessible names, critical pages |
| WCAG 2.2 AA Checklist |
Manual audit checklist by POUR principle |
| ARIA Patterns: Widgets Part 1 |
Fundamentals, dialog, tabs, menu widgets |
| ARIA Patterns: Widgets Part 2 |
Accordion, combobox, live regions, tooltip |
| ARIA Patterns: Mistakes & Reference |
Common ARIA mistakes and roles quick reference |
External Resources
Verification
1---2name: a11y-playwright-testing3description: Accessibility testing for web applications using Playwright (@playwright/test), TypeScript, and axe-core. Use to write, run, or debug WCAG 2.2 AA checks, keyboard and focus tests, ARIA/semantic validation, accessible names, form labels, color contrast, or screen-reader test patterns. Keywords: accessibility, WCAG, axe-core, keyboard navigation, focus management, ARIA.4license: Complete terms in LICENSE.txt5---6
7# Playwright Accessibility Testing (TypeScript)
8
9Comprehensive toolkit for automated accessibility testing using Playwright with TypeScript and axe-core. Enables WCAG 2.2 Level AA compliance verification (superset of 2.1), keyboard operability testing, semantic validation, and accessibility regression prevention.
10
11> **Activation:** This skill is triggered when working with accessibility testing, WCAG compliance, axe-core scans, keyboard navigation tests, focus management, ARIA validation, or screen reader compatibility.
12
13## When to Use This Skill
14
15- **Automated a11y scans** with axe-core for WCAG 2.2 AA compliance
16- **Keyboard navigation tests** for Tab/Enter/Space/Escape/Arrow key operability
17- **Focus management** validation for dialogs, menus, and dynamic content
18- **Semantic structure** assertions for landmarks, headings, and ARIA
19- **Form accessibility** testing for labels, errors, and instructions
20- **Color contrast** and visual accessibility verification
21- **Screen reader** compatibility testing patterns
22
23### Do NOT Use For
24
25- Selenium/Java accessibility testing (use `accessibility-selenium-testing`).
26- Authoring Playwright functional/UI E2E specs (use `playwright-e2e-testing`).
27- Full conformance sign-off — automated axe scans catch ~30-40% of issues; manual audit + assistive-tech testing is still required.
28
29## Prerequisites
30
31| Requirement | Details |
32| ----------- | ------------------------------ |
33| Node.js | v18+ recommended |
34| Playwright | `@playwright/test` installed |
35| axe-core | `@axe-core/playwright` package |
36| TypeScript | Configured in project |
37
38### Quick Setup
39
40```bash
41# Add axe-core to existing Playwright project
42npm install -D @axe-core/playwright axe-core
43```
44
45## First Questions to Ask
46
47Before writing accessibility tests, clarify:
48
491. **Scope**: Which pages/flows are in scope? What's explicitly excluded?
502. **Standard**: WCAG 2.2 AA (default) or specific organizational policy?
513. **Priority**: Which components are highest risk (forms, modals, navigation, checkout)?
524. **Exceptions**: Known constraints (legacy markup, third-party widgets)?
535. **Assistive Tech**: Which screen readers/browsers need manual testing?
54
55---
56
57## Core Principles
58
59### 1. Automation Limitations
60
61> [!] **Critical**: Automated tooling can detect ~30-40% of accessibility issues. Use automation to prevent regressions and catch common failures; **manual audits are required** for full WCAG conformance.
62
63### 2. Semantic HTML First
64
65Prefer native HTML semantics over ARIA. Use ARIA only when native elements cannot achieve the required semantics.
66
67```typescript
68// [ok] Semantic HTML - inherently accessible
69await page.getByRole("button", { name: "Submit" }).click();
70
71// [no] ARIA override - requires manual keyboard/focus handling
72await page.locator('[role="button"]').click(); // Often a <div>
73```
74
75### 3. Locator Strategy as A11y Signal
76
77If you **cannot locate an element by role or label**, it's often an accessibility defect.
78
79| Locator Success | Accessibility Signal |
80| -------------------------------------------- | -------------------------- |
81| `getByRole('button', { name: 'Submit' })` [ok] | Button has accessible name |
82| `getByLabel('Email')` [ok] | Input properly labeled |
83| `getByRole('navigation')` [ok] | Landmark exists |
84| `locator('.submit-btn')` [!] | May lack accessible name |
85
86---
87
88## Key Workflows
89
90### Automated Axe Scan (WCAG 2.2 AA)
91
92```typescript
93import AxeBuilder from "@axe-core/playwright";
94import { test, expect } from "@playwright/test";
95
96test("page has no WCAG 2.2 AA violations", async ({ page }) => {
97 await page.goto("/");
98
99 const results = await new AxeBuilder({ page })
100 .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
101 .analyze();
102
103 expect(results.violations).toEqual([]);
104});
105```
106
107### Scoped Axe Scan (Component-Level)
108
109```typescript
110test("form component is accessible", async ({ page }) => {
111 await page.goto("/contact");
112
113 const results = await new AxeBuilder({ page })
114 .include("#contact-form") // Scope to specific component
115 .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
116 .analyze();
117
118 expect(results.violations).toEqual([]);
119});
120```
121
122### Keyboard Navigation Test
123
124```typescript
125test("form is keyboard navigable", async ({ page }) => {
126 await page.goto("/login");
127
128 // Tab to first field
129 await page.keyboard.press("Tab");
130 await expect(page.getByLabel("Email")).toBeFocused();
131
132 // Tab to password
133 await page.keyboard.press("Tab");
134 await expect(page.getByLabel("Password")).toBeFocused();
135
136 // Tab to submit button
137 await page.keyboard.press("Tab");
138 await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused();
139
140 // Submit with Enter
141 await page.keyboard.press("Enter");
142 await expect(page).toHaveURL(/dashboard/);
143});
144```
145
146### Dialog Focus Management
147
148```typescript
149test("dialog traps and returns focus", async ({ page }) => {
150 await page.goto("/settings");
151 const trigger = page.getByRole("button", { name: "Delete account" });
152
153 // Open dialog
154 await trigger.click();
155 const dialog = page.getByRole("dialog");
156 await expect(dialog).toBeVisible();
157
158 // Focus should be inside dialog
159 await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();
160
161 // Tab should stay trapped in dialog
162 await page.keyboard.press("Tab");
163 await expect(dialog.getByRole("button", { name: "Confirm" })).toBeFocused();
164 await page.keyboard.press("Tab");
165 await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();
166
167 // Escape closes and returns focus to trigger
168 await page.keyboard.press("Escape");
169 await expect(dialog).toBeHidden();
170 await expect(trigger).toBeFocused();
171});
172```
173
174### Skip Link Validation
175
176```typescript
177test("skip link moves focus to main content", async ({ page }) => {
178 await page.goto("/");
179
180 // First Tab should focus skip link
181 await page.keyboard.press("Tab");
182 const skipLink = page.getByRole("link", { name: /skip to (main|content)/i });
183 await expect(skipLink).toBeFocused();
184
185 // Activating skip link moves focus to main
186 await page.keyboard.press("Enter");
187 await expect(page.locator('#main, [role="main"]').first()).toBeFocused();
188});
189```
190
191---
192
193## POUR Principles Reference
194
195| Principle | Focus Areas | Example Tests |
196| ------------------ | ----------------------------------------- | ------------------------------------------------- |
197| **Perceivable** | Alt text, captions, contrast, structure | Image alternatives, color contrast ratio |
198| **Operable** | Keyboard, focus, timing, navigation | Tab order, focus visibility, skip links |
199| **Understandable** | Labels, instructions, errors, consistency | Form labels, error messages, predictable behavior |
200| **Robust** | Valid HTML, ARIA, name/role/value | Semantic structure, accessible names |
201
202---
203
204## Axe-Core Tags
205
206Default: `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22a`, `wcag22aa` (WCAG 2.2 AA). Use `best-practice` for additional checks. See [`references/axe-tags-reference.md`](references/axe-tags-reference.md) for full tag list.
207
208---
209
210## Exception Handling
211
212When exceptions are unavoidable:
213
2141. **Scope narrowly** - specific component/route only
2152. **Document impact** - which WCAG criterion, user impact
2163. **Set expiration** - owner + remediation date
2174. **Track ticket** - link to remediation issue
218
219```typescript
220// [no] Avoid: Global rule disable
221new AxeBuilder({ page }).disableRules(["color-contrast"]);
222
223// [ok] Better: Scoped exclusion with documentation
224new AxeBuilder({ page })
225 .exclude("#third-party-widget") // Known issue: JIRA-1234, fix by Q2
226 .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"])
227 .analyze();
228```
229
230---
231
232## Troubleshooting
233
234| Problem | Cause | Solution |
235| ------------------------------------------------- | ------------------------------ | --------------------------------------- |
236| Axe finds 0 violations but app fails manual audit | Automation covers ~30-40% | Add manual testing checklist |
237| False positive on dynamic content | Content not fully rendered | Wait for stable state before scan |
238| Color contrast fails incorrectly | Background image/gradient | Use `exclude` for known false positives |
239| Cannot find element by role | Missing semantic HTML | Fix markup - this is a real bug |
240| Focus not visible | Missing `:focus` styles | Add visible focus indicator CSS |
241| Dialog focus not trapped | Missing focus trap logic | Implement focus trap (see snippets) |
242| Skip link doesn't work | Target missing `tabindex="-1"` | Add tabindex to main content |
243
244---
245
246## CLI Quick Reference
247
248| Command | Description |
249| ----------------------------------- | -------------------------------------- |
250| `npx playwright test --grep "a11y"` | Run accessibility tests only |
251| `npx playwright test --headed` | Run with visible browser for debugging |
252| `npx playwright test --debug` | Step through with Inspector |
253| `PWDEBUG=1 npx playwright test` | Debug mode with pause |
254
255---
256
257## Red Flags
258
259- Treating a clean axe scan as full WCAG conformance — automation covers only ~30-40% of criteria.
260- Globally disabling rules (e.g., `color-contrast`) instead of scoped `.exclude()` with a documented ticket.
261- Scanning before the page reaches a stable state — async content yields false "0 violations".
262- Skipping keyboard/focus tests because axe passed — focus order and traps need explicit tests.
263
264---
265
266## References
267
268| Document | Content |
269| -------------------------------------------------------------------------------- | ------------------------------------------------ |
270| [Snippets: Setup & Scanning](./references/snippets-setup-and-scanning.md) | axe-core setup, helper, and scanning patterns |
271| [Snippets: Keyboard, Focus, Semantic](./references/snippets-keyboard-focus-semantic.md) | Keyboard navigation, focus management, semantic structure |
272| [Snippets: Visual, Names, Checklist](./references/snippets-visual-names-checklist.md) | Visual accessibility, accessible names, critical pages |
273| [WCAG 2.2 AA Checklist](./references/wcag21aa-checklist.md) | Manual audit checklist by POUR principle |
274| [ARIA Patterns: Widgets Part 1](./references/aria-patterns-widgets-1.md) | Fundamentals, dialog, tabs, menu widgets |
275| [ARIA Patterns: Widgets Part 2](./references/aria-patterns-widgets-2.md) | Accordion, combobox, live regions, tooltip |
276| [ARIA Patterns: Mistakes & Reference](./references/aria-patterns-mistakes.md) | Common ARIA mistakes and roles quick reference |
277
278## External Resources
279
280| Resource | URL |
281| ---------------------------- | --------------------------------------- |
282| WCAG 2.2 Specification | https://www.w3.org/TR/WCAG22/ |
283| WCAG Quick Reference | https://www.w3.org/WAI/WCAG22/quickref/ |
284| WAI-ARIA Authoring Practices | https://www.w3.org/WAI/ARIA/apg/ |
285| axe-core Rules | https://dequeuniversity.com/rules/axe/ |
286
287---
288
289## Verification
290
291- [ ] **axe-core audit passes** — `AxeBuilder.analyze()` returns zero critical violations
292- [ ] **Keyboard navigation tested** — All interactive elements reachable via Tab; focus order is logical
293- [ ] **Color contrast sufficient** — WCAG 2.2 AA minimum contrast ratios met (4.5:1 normal text, 3:1 large text)
294- [ ] **WCAG 2.2 AA conformance** — Tags `wcag22a`/`wcag22aa` included in scans (focus-not-obscured, dragging movements, target-size minimums)