Stop Missing Accessibility Bugs: Scan Page States with axe-core + Playwright
Your page passes axe-core with zero violations. Then someone opens a modal, expands an accordion, or submits a form, and accessibility issues show up that the scan never flagged.
That’s not axe-core failing you; it’s a state problem. axe-core only scans the DOM as it exists right now. If the important part of your UI only shows up after an interaction, you have to trigger that interaction before you scan.
I’ve run axe-core against thousands of pages, maybe even hundreds of thousands. For a long time I just scanned them as-loaded and called it done. Looking back, I know I missed real issues because I scanned the pages in only the default state, not in the states that actually mattered.
Configure your scan list and states
My axe-core + Playwright setup which I’ve been using locally for years is built around one list of pages to scan. A test runner loops through that list and generates results for each page. Adding a “states” concept on top of what I already was using turned out to be pretty easy:
- Keep the single
scanTargetslist. - Allow each target to optionally declare a
statename. - Keep state interactions in one registry (name -> Playwright function).
- In the test loop, run the interaction before
axe.analyze().
That keeps the core test runner generic, while letting you bolt on UI-state-specific checks whenever you need them.
Define targets (with optional state)
export const scanTargets = [
{ name: "Homepage", path: "/" },
{
name: "Signup modal (open)",
path: "/early-access/",
state: "openBannerModal",
includes: [".wpns-modal-shell"]
}
];
The key thing in this page definition is that a state is just a name. No framework magic involved; it only needs to map reliably to an interaction function which does the actions like clicking or typing.
Register named state interactions
Playwright uses a headless browser, but it is still a real browser. You can create states for nearly anything that a user could do in their browser.
import type { Page } from "@playwright/test";
export const interactions: Record<string, (page: Page) => Promise<void>> = {
async openBannerModal(page) {
await page.locator("[data-modal]").click();
await page.locator(".modal.is-open").waitFor({ state: "visible" });
}
};
Run interaction before axe analysis
Here’s the actual loop that goes through the pages, does interactions and then scans the page.
for (const target of scanTargets) {
test(target.name, async ({ page }) => {
await page.goto(target.path, { waitUntil: "networkidle" });
if (target.state) {
const runInteraction = interactions[target.state];
if (!runInteraction) {
throw new Error(`No interaction registered for state "${target.state}"`);
}
await runInteraction(page);
}
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa"])
.analyze();
expect(results.violations).toEqual([]);
});
}
If someone adds a new state to a target and forgets to register the interaction, the test fails at the throw. A missing interaction won’t quietly pass when it shouldn’t.
Why I keep coming back to this
- Adding a new page state is one target + one interaction.
- Works for modals, tabs, drawers, validation errors, dynamic alerts and everything else.
- The core test loop stays stable, easily discoverable and reliable.
Avoid the mistakes I ran into over time
- Use state names that describe intent (
openBannerModal, notstep2) so you can easily identify them in the code. - Always wait for a stable post-interaction condition before scanning.
- Keep interactions deterministic; you can only make repeatable tests with repeatable states.
- Be careful with aggressive selector scoping: it reduces noise, but it can hide real issues outside your include boundary.
The reasons that made me look for this solution in the first place
Buttons that open modals, like search inputs or page options. In so many cases, when I scanned pages, I ran into modals.
The default page state and the modal-open state are different. A missing close-button name only shows up once the modal is actually open. Scan the page at load only, and you’d never know it was there. Actual users know it’s an issue because they run into the problem.
Same URL, two different states, two different sets of results. That’s the whole argument for testing states in the first place.
A green axe-core run tells you the states you scanned are clean of the issues it can scan for. It doesn’t tell you anything about the things it might find in states you didn’t setup before the scan. If your UI changes after user interaction, your testing needs to change with it.