Playwright does not test accessibility on its own. It drives the browser, and @axe-core/playwright runs the axe engine inside the page it has loaded. Understanding that split is what stops teams believing a green suite means an accessible product.
This guide covers wiring axe into a Playwright suite, scoping scans so they are actionable, and being honest about the large share of accessibility work automation cannot reach.
What automated accessibility testing actually covers
Playwright's own documentation carries the caveat, and it is worth repeating before any code:
> Automated accessibility tests can detect some common accessibility problems such as > missing or invalid properties. But many accessibility problems can only be discovered > through manual testing.
Automation is reliable at machine-checkable properties: a missing alt, an input with no label, insufficient colour contrast, an invalid ARIA attribute. These are real defects and catching them automatically is worth doing.
It cannot evaluate whether your alt text is meaningful, whether focus order matches visual order in a way a keyboard user can follow, or whether an error message makes sense when read aloud in a screen reader. Those need a person.
The correct framing for stakeholders: automated scans are a regression guard against mechanical regressions. They are not a compliance audit, and describing a passing suite as "WCAG compliant" is a claim your suite cannot support.
Setting it up
npm install --save-dev @playwright/test @axe-core/playwrightA scan of a full page is four lines of meaningful code.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('checkout page has no automatically detectable violations', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});analyze() runs axe inside the loaded page and returns violations, passes, incomplete results and inapplicable rules. Asserting on violations alone is the common starting point. For more, see our guide to Playwright test hooks.
Scoping scans so the output is actionable
A full-page scan on a complex application returns violations from headers, footers, cookie banners and third-party widgets. The genuine defect in the component you changed is buried, so people stop reading the output.
Scan a component, not the page
test('payment form is accessible', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.include('#payment-form')
.analyze();
expect(results.violations).toEqual([]);
});Exclude what you do not control
const results = await new AxeBuilder({ page })
.exclude('#intercom-widget')
.exclude('.third-party-ad')
.analyze();Excluding a third-party widget is a legitimate engineering decision, because you cannot fix someone else's iframe. Record the exclusion and raise it with that vendor rather than letting it silently disappear.
Filter to the standard you are held to
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();Tag filtering matters commercially. If your obligation is WCAG 2.1 Level AA, scanning against best-practice rules produces findings nobody is required to fix, which makes the report easy to dismiss. For more, see our guide to accessibility testing tools.
Testing the states a scan never reaches
The biggest gap in most accessibility suites is not rule configuration. It is that scans run against the initial render, and accessibility defects concentrate in states users reach by interacting.
test('validation errors are announced', async ({ page }) => {
await page.goto('/checkout');
// Trigger the error state before scanning
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Card number is required')).toBeVisible();
const results = await new AxeBuilder({ page })
.include('#payment-form')
.analyze();
expect(results.violations).toEqual([]);
});Scan after opening modals, expanding accordions, triggering validation and loading asynchronous content. A form that passes empty and fails with errors displayed is the normal case, not the exception.
Keyboard and focus, which axe cannot judge
Keyboard operability is where automation stops and Playwright still helps, because you can assert on focus directly.
test('modal traps focus and restores it on close', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Delete account' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Focus must move into the dialog
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
// Escape closes it and focus returns to the trigger
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(page.getByRole('button', { name: 'Delete account' })).toBeFocused();
});This is not an axe scan and it catches a defect axe cannot: focus lost to the document body after closing a dialog, which strands keyboard users.
Using getByRole throughout your normal suite is itself an accessibility check. If a button cannot be found by its accessible name, screen reader users cannot find it either.
Reporting violations usefully
expect(results.violations).toEqual([]) fails with an unreadable object diff. Attach the findings instead.
test('product page accessibility', async ({ page }, testInfo) => {
await page.goto('/product/sku-1024');
const results = await new AxeBuilder({ page }).analyze();
await testInfo.attach('axe-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expect(results.violations.map(v => `${v.id}: ${v.help}`)).toEqual([]);
});Mapping to id: help before asserting means the failure message names the rule and the fix rather than dumping a nested object. The full detail stays attached to the report for whoever picks it up.
Handling known violations without muting the suite
Every existing product has accessibility debt. A suite that fails on all of it from day one gets skipped, so you need a way to hold the line on new defects while legacy findings are worked through.
The approach that survives is a baseline file: record the violations that exist today, fail only on violations that are not in it, and shrink it deliberately.
import knownViolations from './a11y-baseline.json';
test('catalogue page has no NEW violations', async ({ page }) => {
await page.goto('/catalogue');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
const newOnes = results.violations.filter(
v => !knownViolations.includes(v.id),
);
expect(newOnes.map(v => `${v.id}: ${v.help}`)).toEqual([]);
});Two rules keep this honest. The baseline may only shrink, never grow, enforced in review. And every entry needs an owner and a target date, otherwise the file becomes a permanent exemption list rather than a temporary one.
Rules worth disabling, and rules worth arguing about
Occasionally a rule genuinely does not apply. disableRules exists for that.
const results = await new AxeBuilder({ page })
.disableRules(['region'])
.analyze();Disable a rule only when you can state why in one sentence, and record that sentence next to the code. "The component renders inside a host page we do not control, so landmark regions are not ours to define" is a reason. "It was noisy" is a decision to ship inaccessible software, written as a configuration change.
Colour contrast is the rule teams most often try to disable, usually because it conflicts with a brand palette. That conflict is real and it is a design decision, not a testing one. Escalate it to whoever owns the palette rather than silencing the check.
Common mistakes
The first is the one that causes commercial trouble. A passing axe suite tells you the mechanical checks passed, and saying "we are WCAG 2.1 AA compliant" on that basis is a claim your evidence does not support. If someone needs that statement, it requires a manual audit against every applicable success criterion.
A realistic CI shape
Fail the build on new violations in components under active change. Report, rather than fail, on pages with known legacy debt, otherwise the suite gets disabled in week two.
Agree the exact standard first: WCAG version, conformance level, and which pages are in scope. Without that, "accessible" is a matter of opinion and the suite has no defined target.
If you need an accessibility audit that covers what automation cannot reach, our accessibility audit service combines automated scanning with manual and assistive-technology testing.