axe-playwright
Axe finds only part of the WCAG failures. Treat a green scan as "no detectable violations", not as compliance. Keyboard, focus order and screen reader checks stay manual.
Setup
npm i -D @playwright/test @axe-core/playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
Create a new AxeBuilder({ page }) per scan. Do not reuse a builder across pages.
Basic scan
test('home has no a11y violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Builder methods (verified against the package README)
include(selector)andexclude(selector)scope the scan. Both chain.withTags(string | string[])limits rules by tag.withRules(string | string[])runs only those rule ids.disableRules(string | string[])skips rule ids.options(axeRunOptions)passes rawaxe.runoptions.setLegacyMode(boolean)toggles legacy frame testing.analyze()returns the results object.
Tags
Use ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] for the usual AA target. Without withTags, axe also runs best-practice rules, which fail builds on non-WCAG items.
new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
Scoping
new AxeBuilder({ page }).include('main').exclude('[data-testid="3d-canvas"]').analyze();
Prefer exclude for third-party widgets and disableRules only for a known issue with a tracked ticket. Leave a comment with the ticket id beside it.
Known limits
- color-contrast cannot read pixels from
<canvas>or WebGL. Text drawn inside them is not checked, and DOM text over them is reported as "incomplete", not as a violation. - Gradient, image and semi-transparent backgrounds usually land in
results.incomplete. Review these by hand or with a contrast tool on a screenshot. - Overlapped elements (sticky nav, modals, pseudo-element overlays) produce incomplete results for the covered text.
- Animations in flight give unstable colors. Wait for them to settle or emulate reduced motion before scanning.
- Axe sees the DOM state at scan time. Open menus, dialogs and tabs must be opened first.
Always assert on violations, and log incomplete so skipped contrast checks stay visible.
Themes
Switch theme first, then scan. Match how the app stores it.
for (const scheme of ['light', 'dark'] as const) {
test(`home a11y (${scheme})`, async ({ page }) => {
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/');
const results = await new AxeBuilder({ page }).withTags(['wcag2aa']).analyze();
expect(results.violations).toEqual([]);
});
}
If the theme is a class or attribute (next-themes), set it with page.addInitScript writing localStorage, or click the toggle, then wait for the attribute before scanning.
Per-route scans
const routes = ['/', '/en/notes', '/es/notes'];
for (const route of routes) {
test(`a11y ${route}`, async ({ page }) => {
await page.goto(route);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
}
Reporting in CI
Attach the full results so failures can be inspected from the HTML report.
test('scan', async ({ page }, testInfo) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
await testInfo.attach('accessibility-scan-results', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
const summary = results.violations.map(v => `${v.id} (${v.impact}) x${v.nodes.length}: ${v.nodes[0].target.join(' ')}`);
expect(summary).toEqual([]);
});
Asserting on a mapped summary makes the failure diff readable. Wrap the setup in a fixture (test.extend) to share tags and excludes across specs.
Author: Ender Puentes (with Claude Code) Source: https://playwright.dev/docs/accessibility-testing