Playwright accessibility testing with axe: UI states
Test dialogs, validation and navigation with Playwright and axe. Includes runnable examples, focus checks, CI guidance and coverage limits.
Playwright accessibility testing is most useful when a test reaches the interface state a person needs: an open dialog, a rejected form submission, or expanded navigation. A scan of the initial page cannot inspect controls that have not been rendered. Playwright drives the interaction; axe-core checks the resulting DOM against selected rules.1
This guide gives you a runnable local fixture and three tests, then explains what to change for an application. It is a regression-testing recipe, not a WCAG conformance assessment. Manual accessibility assessment and inclusive user testing remain necessary.1
Choose states, not just routes
Start with one important task, such as changing an email address. Record the starting account/data, action, expected result and scan boundary. Include the error and recovery paths, not only a successful submission. A route may contain several independently testable states.
Use STATE as a planning checklist: select a state, trigger it, assert the result, triage findings and establish evidence. It is an editorial mnemonic, not a standard. The sequence below has a text equivalent in those five steps.
Prefer a small explicit inventory over a route count. For account settings, cover dialog entry and dismissal, invalid input and successful recovery. For navigation, cover the expanded and collapsed states at a narrow viewport. Add authentication, permissions, feature flags and loading failures where they change the controls a user encounters.
Wait for the result of the action
Playwright does not apply identical waiting checks to every action. click() checks visibility, stability, event reception and enabled state. fill() checks visibility, enabled state and editability; it does not require stability or event reception. A completed click or fill is not proof that validation or asynchronous rendering has finished.2
Use a retrying assertion on the actual result before calling analyze(): a named dialog is visible, an error appears, or a status message contains the expected text. Avoid arbitrary sleeps. If your application loads options after opening a dialog, asserting only that the shell is visible is insufficient; wait for the options or a documented ready state too.12
Run the example locally
The example uses @playwright/test 1.58.1 and @axe-core/playwright 4.11.1. Save the following files in an empty directory with Node.js and npm available. The configuration uses an installed Google Chrome; it does not download a browser. For bundled Chromium instead, remove channel and run npx playwright install chromium.
npm init -y
npm install --save-dev --save-exact @playwright/test@1.58.1 @axe-core/playwright@4.11.1
npx playwright testRun the final command after saving all three files. Keep the generated lockfile in version control: the wrapper's version alone does not pin every transitive dependency. On 5 October 2026, all three tests passed in local Chrome with axe-core 4.11.4, the wrapper’s resolved dependency. This was a synthetic fixture run, not a production account-service test.
1. Save fixture.html
The fixture uses a native modal dialog, client-side email validation and ordinary disclosure navigation. Its Save action changes local text only; it sends no email and stores nothing. The focus loop handles only this fixture's inputs and buttons; a reusable implementation must account for other controls and hidden or disabled elements. This is test data, not a production component library.
<!doctype html><html lang="en"><title>Account settings fixture</title>
<style>body { color:#111; background:#fff; font:18px sans-serif } :focus-visible {outline:3px solid #005fcc} dialog {color:#111;background:#fff} button, input, a {font:inherit; min-height:32px; margin:4px} a {display:inline-block; padding:4px}</style>
<main><h1>Account settings</h1>
<button id="open">Edit email</button>
<dialog aria-labelledby="heading" aria-modal="true">
<h2 id="heading">Edit email</h2><form novalidate>
<label for="email">Email</label><input id="email" type="email" autocomplete="email" autofocus>
<p id="error" hidden>Enter a valid email address.</p>
<button type="submit">Save</button><button type="button" id="close">Cancel</button>
</form></dialog>
<button id="nav" aria-expanded="false" aria-controls="links">Navigation</button>
<nav id="links" aria-label="Account" hidden><a href="#profile">Profile</a><a href="#help">Help</a></nav>
<p role="status" id="status"></p></main>
<script>
const dialog = document.querySelector('dialog');
const opener = document.querySelector('#open');
const email = document.querySelector('#email');
const error = document.querySelector('#error');
const nav = document.querySelector('#nav');
const links = document.querySelector('#links');
opener.onclick = () => dialog.showModal();
document.querySelector('#close').onclick = () => dialog.close();
dialog.addEventListener('close', () => opener.focus());
dialog.addEventListener('keydown', event => {
const controls = [...dialog.querySelectorAll('input, button')];
const first = controls[0], last = controls.at(-1);
if (event.key === 'Tab' && event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
if (event.key === 'Tab' && !event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
});
document.querySelector('form').onsubmit = event => {
event.preventDefault();
const invalid = !email.value || !email.validity.valid;
error.hidden = !invalid;
if (invalid) { email.setAttribute('aria-invalid','true'); email.setAttribute('aria-describedby','error'); email.focus(); }
else { email.removeAttribute('aria-invalid'); email.removeAttribute('aria-describedby'); dialog.close(); document.querySelector('#status').textContent = 'Email saved.'; }
};
nav.onclick = () => { const open = nav.getAttribute('aria-expanded') === 'true'; nav.setAttribute('aria-expanded', String(!open)); links.hidden = open; };
</script></html>2. Save playwright.config.mjs
import { defineConfig } from '@playwright/test';
export default defineConfig({ testMatch: /states.spec.mjs/, workers: 1, retries: 0, use: { channel: 'chrome' }, reporter: [['list'], ['json', { outputFile: 'test-results.json' }]] });3. Save states.spec.mjs
Role and label locators express the expected accessible names. They are useful contracts, but locating a control by role does not prove its entire interaction is accessible.6
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFileSync } from 'node:fs';
test.beforeEach(async ({ page }) => {
await page.setContent(readFileSync('fixture.html', 'utf8'));
});
async function scan(page, testInfo, state) {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
await testInfo.attach(`axe-${state}`, {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
// This small fixture has a strict review gate; real apps need triage.
expect(results.incomplete).toEqual([]);
}
test('dialog opens, contains focus and returns it', async ({ page }, testInfo) => {
const opener = page.getByRole('button', { name: 'Edit email', exact: true });
await opener.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Edit email', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog).toHaveAttribute('aria-modal', 'true');
const email = dialog.getByLabel('Email', { exact: true });
const save = dialog.getByRole('button', { name: 'Save', exact: true });
const cancel = dialog.getByRole('button', { name: 'Cancel', exact: true });
await expect(email).toBeFocused();
await page.keyboard.press('Tab');
await expect(save).toBeFocused();
await page.keyboard.press('Tab');
await expect(cancel).toBeFocused();
await page.keyboard.press('Tab');
await expect(email).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(cancel).toBeFocused();
await scan(page, testInfo, 'dialog');
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
await page.keyboard.press('Enter');
await expect(email).toBeFocused();
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Enter');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
});
test('invalid email is associated and can be corrected', async ({ page }, testInfo) => {
await page.getByRole('button', { name: 'Edit email', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Edit email', exact: true });
await expect(dialog).toBeVisible();
const email = dialog.getByLabel('Email', { exact: true });
await email.fill('wrong');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('#error')).toBeVisible();
await expect(email).toHaveAttribute('aria-invalid', 'true');
await expect(email).toHaveAccessibleDescription('Enter a valid email address.');
await expect(email).toBeFocused();
await scan(page, testInfo, 'invalid');
await email.fill('reader@example.com');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole('status')).toHaveText('Email saved.');
await expect(page.locator('#error')).toBeHidden();
await expect(page.locator('#email')).not.toHaveAttribute('aria-invalid', 'true');
await scan(page, testInfo, 'saved');
});
test('navigation disclosure exposes ordinary links', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 375, height: 667 });
const trigger = page.getByRole('button', { name: 'Navigation', exact: true });
const links = page.getByRole('navigation', { name: 'Account', exact: true });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(links).toBeHidden();
await trigger.focus();
await page.keyboard.press('Space');
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await expect(links).toBeVisible();
await page.keyboard.press('Tab');
await expect(links.getByRole('link', { name: 'Profile', exact: true })).toBeFocused();
await scan(page, testInfo, 'navigation');
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Enter');
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(links).toBeHidden();
await expect(trigger).toBeFocused();
await expect(page.getByRole('link', { name: 'Profile', exact: true })).toHaveCount(0);
await scan(page, testInfo, 'navigation-collapsed');
});What the assertions establish
The first test activates the dialog by keyboard, waits for its accessible name and visible state, checks entry focus, then checks both ends of its Tab sequence. Escape and the visible Cancel button close it and restore focus to the opener. The APG dialog pattern describes these behaviors, but initial focus need not always be the first input: long content may need a heading, and a destructive confirmation may need its safer action focused. Return focus can also move to a logical next step when the opener disappears or the workflow requires it.3
The fixture's native showModal() provides background inertness. A custom dialog needs a separate check that background controls cannot be operated; setting aria-modal="true" alone does not implement that behavior.3 Also inspect visible focus and the reading order. Programmatic focus assertions cannot tell you whether a focus indicator is obscured by a sticky header.
The validation test waits for the visible error, aria-invalid, the field's accessible description and focus. It then supplies valid input and verifies dismissal, cleared error state and the success message. Checking aria-describedby alone would be weaker: the referenced element could be missing or contain the wrong text. The final attribute assertion uses a DOM locator because the closed dialog is no longer exposed to the default role locator.
A status-region text assertion establishes the DOM update, not what a screen reader announced or when. Test announcements with your supported browser and assistive-technology combinations. In an application, also wait for the actual saved-data response and assert the persisted value; this fixture intentionally does neither.
Navigation disclosure is not an ARIA menu
The third test uses a button that expands ordinary links. It checks aria-expanded, visibility, Space activation, Tab into the first link and collapse via Enter. Typical website navigation does not need the ARIA menu role or its specialized keyboard model.7
For an actual command menu, use the menu-button contract instead: a button with aria-haspopup="menu", synchronized aria-expanded, a named menu and correctly named menu items. Enter and Space should open it and focus the first item.4 Add tests for the menu's supported arrow-key navigation, activation and Escape/focus return. Do not paste disclosure assertions onto a menu widget and call that coverage complete.
Interpret the axe result, including incomplete
The helper selects WCAG A/AA tags through 2.2 available in this axe version. This selects tagged automated rules, not every success criterion at those levels. Default axe scans also include rules categorized as best practice; filtering by WCAG tags changes that scope.15
The result contains violations, passes, incomplete and inapplicable. An incomplete result needs review: axe could not decide whether the affected node passed or failed. An inapplicable rule found no relevant content; it is not evidence that you tested that feature.5
This small fixture fails on both violations and incomplete findings, after attaching the complete result. For a real application, you may instead route incomplete findings into an owned review queue. Make that an explicit policy with a review deadline, rather than silently dropping the array or treating it as a pass. Save the engine version from results.testEngine, browser/project, state name and commit alongside artifacts. Rebaseline deliberately after dependency upgrades.
Be precise about scan boundaries
These tests scan the whole current page. .include('#panel') can reduce noise for a component check, but does not establish coverage outside that subtree. .exclude() removes the selected element and its descendants from all rules, so a broad exclusion can hide unrelated regressions.1
The Playwright axe wrapper automatically injects into frames. Its legacy mode disables cross-origin frame testing and reports untested frames; a scan error or untested frame is a coverage gap, not a clean result.8 Wait for embedded content to load, inspect frame-related results and exercise important embedded workflows explicitly.
Playwright locators generally support open shadow roots, except XPath; closed shadow roots are unsupported. Axe can traverse open shadow DOM, but locator success does not itself establish scan coverage.56 Document closed roots and third-party widgets separately. The fixture above contains no iframe or shadow root, so its passing result makes no claim about either.
Put useful failures into CI
Keep fixture users synthetic, use dedicated accounts for authenticated tests and restrict artifact access. Axe results contain DOM snippets; traces, screenshots and logs can expose personal data and session details. Set retention limits and sanitize before sharing.
Run the critical state tests on pull requests. Add representative mobile viewports, other supported browsers and manual checks on a schedule suited to the product. A narrow Chrome fixture result should never be relabeled as cross-browser coverage. For failures, retain the state, rule ID, affected targets, reproduction and assigned owner. Prefer a targeted, expiring exception to disabling a rule across the application.
For account flows, extend this approach to fallback and recovery screens as described in the passkey rollout guide. Begin with a failing user task, write its observable contract, then repair the component and rerun that state. That produces a regression test someone else can maintain.
Key Takeaways
- 1Reach and assert each rendered state before running axe; an action completing is not a readiness signal.
- 2Check dialog names, focus containment, dismissal and the appropriate focus destination.
- 3Verify visible errors, field associations and successful recovery together.
- 4Treat incomplete findings as review work, and record versions and scan boundaries.
- 5Use STATE to select and trigger states, assert outcomes, triage findings and establish evidence.
Conclusion
Choose a user-critical state and define its visible, semantic and keyboard outcomes before scanning. Keep the test, scan scope and review evidence together so a future failure is reproducible.
Frequently Asked Questions
Does a passing axe scan prove WCAG conformance?
No. It reports selected automated checks for the rendered state. Manual assessment, assistive-technology testing and inclusive user testing remain necessary.
Should I scan the entire page or one component?
Use a full-page scan for the state when feasible. A scoped scan is useful for a component, but record its boundary and maintain separate checks for excluded content.
What should I do with incomplete results?
Review the affected nodes. Axe could not determine pass or fail. Either fail the test pending review or track the findings in an owned, time-bound queue.
Does fill wait for an element to stop moving?
No. Playwright checks that the element is visible, enabled and editable for fill. Assert the application’s resulting state separately before scanning.
Can I reuse these tests for production?
Replace the synthetic fixture with your application and update the accessible names, data and readiness assertions. Add supported browsers, viewports and manual checks; the example does not test persistence or a real service.
Sources
- https://playwright.dev/docs/accessibility-testing
- https://playwright.dev/docs/actionability
- https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal
- https://www.w3.org/WAI/ARIA/apg/patterns/menu-button
- https://www.deque.com/axe/core-documentation/api-documentation
- https://playwright.dev/docs/locators
- https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation
- https://raw.githubusercontent.com/dequelabs/axe-core-npm/0bcb1850683addb69343067263aa3fcd4ddd0561/packages/playwright/README.md
Written by
Hamza DiazHamza Diaz is the founder of Optijara, where he builds practical AI agents, automation systems, and Copilot workflows for service businesses. He writes about AI operations, agent strategy, and real-world implementation for teams that want usable systems instead of hype.
