QA How-To
Playwright Axe Scan Specific Components Examples: A Practical Tutorial
Use these playwright axe scan specific components examples to test dialogs, forms, and widgets with scoped, reliable accessibility checks in CI today.
17 min read | 2,977 words
TL;DR
Create an AxeBuilder with the Playwright page, call include() with a CSS selector for the rendered component, run analyze(), and assert that violations is empty. Scoped scans give focused ownership and faster diagnosis, but they should complement a smaller full-page accessibility suite.
Key Takeaways
- Use AxeBuilder.include() to restrict analysis to a stable CSS selector.
- Open or render a component before scanning it, especially dialogs and menus.
- Assert that violations is empty while preserving useful failure details.
- Keep global document checks separate because a scoped scan cannot detect every page-level problem.
- Use reusable fixtures and helpers to make component scans consistent across a suite.
- Exclude only documented third-party regions and review exclusions regularly.
Playwright axe scan specific components examples all follow the same reliable pattern: render the component in its real state, scope AxeBuilder with include(), analyze that region, and assert on the returned violations. This keeps a dialog, form, navigation widget, or other owned UI region accountable without mixing its results with unrelated page content.
This tutorial builds a reusable TypeScript setup with @axe-core/playwright. For the broader strategy, including full-page audits, CI design, and coverage limits, read the Playwright accessibility automation complete guide.
A scoped Axe scan is not a replacement for a page-level scan. It is a precise layer that makes component failures easier to assign, reproduce, and fix while your wider suite protects document structure and cross-component behavior.
What You Will Build
By the end, you will have:
- A Playwright project configured for Axe accessibility analysis.
- A baseline test that scans one component by CSS selector.
- Runnable examples for a form, dialog, and repeated card component.
- A reusable fixture that exposes a component-scanning helper.
- Focused reports with actionable violation details.
- A balanced suite that combines scoped and full-page coverage.
The examples use a small local fixture page so you can paste them into any Playwright repository and run them without depending on a production site. You can then replace the fixture URL and selectors with those from your application.
Prerequisites
Use Node.js 20 or newer and a current Playwright Test release. The examples are version-agnostic within the current Playwright API and use the supported @axe-core/playwright integration. Start in an existing Node project or create an empty directory, then install the packages:
npm init -y
npm install -D @playwright/test @axe-core/playwright typescript
npx playwright install chromium
Confirm the runner is available:
npx playwright --version
You should see a version number and no module resolution error. You also need basic familiarity with Playwright locators, TypeScript, and accessible HTML. Axe finds many rules-based issues, but it cannot decide whether focus order is logical, alternative text is meaningful, or a live announcement is understandable.
| Scan approach | Best use | Main strength | Important limitation |
|---|---|---|---|
| Full page | Smoke tests and templates | Broad rule coverage | Results may span several owners |
| Specific component | Forms, dialogs, widgets | Focused failures and ownership | Misses issues outside the included region |
| Component plus exclusions | Owned UI beside third-party content | Avoids unactionable vendor noise | Exclusions can hide real defects |
| Manual assistive technology check | Release validation | Finds usability and interaction problems | Requires human judgment |
Step 1: Create a Runnable Accessibility Fixture
Create tests/fixtures/components.html. It contains a form, a button that opens a native dialog, and a list of cards. The markup is intentionally accessible so the first run establishes a green baseline.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Component accessibility fixture</title>
</head>
<body>
<main>
<form id="profile-form">
<h1>Profile</h1>
<label for="display-name">Display name</label>
<input id="display-name" name="displayName" required />
<button type="submit">Save profile</button>
</form>
<button id="open-help" type="button">Open help</button>
<dialog id="help-dialog" aria-labelledby="help-title">
<h2 id="help-title">Profile help</h2>
<p>Use the name that teammates recognize.</p>
<button id="close-help" type="button">Close</button>
</dialog>
<section id="plans" aria-labelledby="plans-title">
<h2 id="plans-title">Plans</h2>
<article class="plan-card">
<h3>Starter</h3>
<a href="#starter">Choose Starter</a>
</article>
<article class="plan-card">
<h3>Team</h3>
<a href="#team">Choose Team</a>
</article>
</section>
</main>
<script>
const dialog = document.querySelector('#help-dialog');
document.querySelector('#open-help').addEventListener('click', () => dialog.showModal());
document.querySelector('#close-help').addEventListener('click', () => dialog.close());
</script>
</body>
</html>
Serve the repository root during tests. Add this playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://127.0.0.1:4173',
browserName: 'chromium',
},
webServer: {
command: 'npx http-server . -p 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
},
});
Install the small static server with npm install -D http-server. In an existing app, keep your normal webServer command and navigate to a real route instead.
Verify: Run npx playwright test --list. Playwright should load the configuration without an import error. Open http://127.0.0.1:4173/tests/fixtures/components.html while the server is running and confirm that the profile form, help button, and two plan cards render.
Step 2: Run Playwright Axe Scan Specific Components Examples
Create tests/component-accessibility.spec.ts. Import AxeBuilder from @axe-core/playwright, wait for the component through a web-first assertion, include its CSS selector, and analyze it.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('profile form has no automatically detectable violations', async ({ page }) => {
await page.goto('/tests/fixtures/components.html');
const profileForm = page.locator('#profile-form');
await expect(profileForm).toBeVisible();
const results = await new AxeBuilder({ page })
.include('#profile-form')
.analyze();
expect(results.violations).toEqual([]);
});
include() accepts a CSS selector understood by Axe. Axe builds its analysis context from the matching node and its descendants. The visibility assertion is useful synchronization, not just a test claim. It prevents analysis before client-rendered content exists.
Do not pass a Playwright Locator to include(). The supported API takes a string selector. Keep the Playwright locator for interaction and waiting, then give Axe a stable CSS selector such as an ID, a dedicated component attribute, or a narrowly defined class.
Verify: Run npx playwright test tests/component-accessibility.spec.ts. The test should pass. Change the label to a plain <span>Display name</span>, rerun, and confirm Axe reports a label-related violation for the input. Restore the label before continuing.
Step 3: Make Violation Failures Easy to Diagnose
An empty-array assertion is simple, but the default diff can become noisy. Add a serializer that keeps the rule ID, impact, help text, documentation URL, selectors, and failure summary. This gives developers the evidence they need without copying the complete Axe result object into logs.
import type { Result } from 'axe-core';
type ViolationSummary = {
id: string;
impact: Result['impact'];
help: string;
helpUrl: string;
nodes: Array<{ target: string[]; failureSummary?: string }>;
};
function summarizeViolations(violations: Result[]): ViolationSummary[] {
return violations.map(({ id, impact, help, helpUrl, nodes }) => ({
id,
impact,
help,
helpUrl,
nodes: nodes.map(({ target, failureSummary }) => ({
target: target.map(String),
failureSummary,
})),
}));
}
test('profile form exposes an accessible structure', async ({ page }) => {
await page.goto('/tests/fixtures/components.html');
await expect(page.locator('#profile-form')).toBeVisible();
const { violations } = await new AxeBuilder({ page })
.include('#profile-form')
.analyze();
expect(summarizeViolations(violations)).toEqual([]);
});
The target is an Axe selector path, not a Playwright locator recommendation. Treat it as diagnostic evidence. Prefer your own resilient locator when reproducing the issue because generated selector paths can depend on DOM structure.
For team repositories, attach the summary as JSON when a test fails. Avoid snapshots of the entire Axe response because metadata can change after dependency updates and produce review noise. Assert the actual current result instead of approving a stale accessibility snapshot.
Verify: Introduce the unlabeled input defect again. The failure should show id, helpUrl, and a target containing #display-name. Restore the valid <label> and confirm the test returns to green.
Step 4: Scan an Interactive Dialog in Its Open State
A dialog that is closed or absent cannot represent the state a user experiences. Open it through its accessible button, assert its state, and then scan only the dialog.
test('open help dialog has no detectable violations', async ({ page }) => {
await page.goto('/tests/fixtures/components.html');
await page.getByRole('button', { name: 'Open help' }).click();
const dialog = page.getByRole('dialog', { name: 'Profile help' });
await expect(dialog).toBeVisible();
const { violations } = await new AxeBuilder({ page })
.include('#help-dialog')
.analyze();
expect(summarizeViolations(violations)).toEqual([]);
});
This order matters. Scanning before the click either excludes the closed dialog from meaningful analysis or tests a hidden state that users never operate. The same rule applies to expanded menus, validation messages, tab panels, autocomplete results, and drawers. Move the UI into each important state before calling analyze().
Axe checks rules that can be determined from the DOM and computed styles. It does not prove that focus moved into the dialog, remained trapped when appropriate, or returned to the trigger after close. Add explicit Playwright assertions for those behaviors. The Playwright keyboard focus order step-by-step tutorial shows how to cover focus behavior that an Axe scan cannot judge.
Verify: Run the dialog test headed with npx playwright test -g "open help dialog" --headed. You should see the modal open before the test passes. Remove aria-labelledby temporarily and verify that Axe flags the dialog naming problem, then restore it.
Step 5: Test Repeated Components Individually
A single scan of #plans tells you the region failed, but a parameterized test can identify the exact component instance. Give repeated components stable identities in production, preferably an application-level ID or a data-component attribute. For this fixture, use each card's heading to select the card and Axe's include() to scope by position.
const plans = [
{ name: 'Starter', selector: '#plans .plan-card:nth-of-type(1)' },
{ name: 'Team', selector: '#plans .plan-card:nth-of-type(2)' },
];
for (const plan of plans) {
test(`${plan.name} plan card has no detectable violations`, async ({ page }) => {
await page.goto('/tests/fixtures/components.html');
const card = page.locator(selectorFor(plan.selector));
await expect(card).toContainText(plan.name);
const { violations } = await new AxeBuilder({ page })
.include(plan.selector)
.analyze();
expect(summarizeViolations(violations)).toEqual([]);
});
}
function selectorFor(value: string): string {
return value;
}
The tiny selectorFor function only makes the distinction between Playwright use and Axe context explicit. In real code, you can call page.locator(plan.selector) directly. A more durable fixture would add data-plan-id="starter" and data-plan-id="team", avoiding structural nth-of-type() selectors.
Use one test per instance when content or configuration changes the accessible result. If every instance has identical markup and only its text differs, one representative component test plus an integration-level region scan may be enough. Choose coverage based on component variation, not raw instance count.
Verify: Run npx playwright test -g "plan card". The report should list two independently passing tests. Remove the href from the Team link and confirm only the Team case fails or ceases to expose the expected link semantics, depending on the resulting markup and enabled Axe rules. Restore the attribute.
Step 6: Create a Reusable Scoped Scan Fixture
Centralize the scan pattern so tests do not invent different assertions or forget to wait for their component. Extend Playwright's base test with a scanComponent fixture that accepts a CSS selector and returns a concise violation summary.
// tests/support/a11y-test.ts
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import type { Result } from 'axe-core';
type A11yFixtures = {
scanComponent: (selector: string) => Promise<Result[]>;
};
export const test = base.extend<A11yFixtures>({
scanComponent: async ({ page }, use) => {
await use(async (selector: string) => {
await expect(page.locator(selector)).toBeVisible();
const { violations } = await new AxeBuilder({ page })
.include(selector)
.analyze();
return violations;
});
},
});
export { expect };
Consume it from a new spec. Keeping the returned value as Result[] lets each test decide whether to apply a strict zero-violation gate or a temporary, explicitly reviewed policy. The default should remain zero.
// tests/scoped-fixture.spec.ts
import { test, expect } from './support/a11y-test';
test('profile form passes the shared component scan', async ({
page,
scanComponent,
}) => {
await page.goto('/tests/fixtures/components.html');
const violations = await scanComponent('#profile-form');
expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
});
Do not hide navigation or component interaction inside a generic scan fixture. A test should plainly show which route and UI state it covers. The fixture owns only the repeated mechanics: visibility synchronization, Axe construction, inclusion, and analysis.
Verify: Run npx playwright test tests/scoped-fixture.spec.ts. Confirm the shared fixture test passes. Rename #profile-form in only the test and confirm the visibility assertion times out at the incorrect selector before Axe runs, giving a clear setup failure.
Step 7: Configure Rules and Exclusions Deliberately
You can combine include(), exclude(), and rule configuration on one builder. Use exclusions only for content your team cannot change, and document the reason beside the code. Never exclude a failing owned element just to make CI green.
test('profile form meets the WCAG tag policy', async ({ page }) => {
await page.goto('/tests/fixtures/components.html');
const { violations } = await new AxeBuilder({ page })
.include('#profile-form')
.exclude('[data-third-party-widget]')
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(summarizeViolations(violations)).toEqual([]);
});
Tags make policy visible and reproducible. Select tags that match your organization's documented conformance target, then keep them consistent across component and full-page suites. Do not assume tags cover every legal or usability obligation. Axe results are evidence from automated rules, not a certification.
If a component contains an embedded vendor widget, prefer a narrow exclusion inside an already narrow include context. Record the vendor, owner, reason, and review date in your test or issue tracker. If the widget is essential to a task, add a separate manual test because users still experience it even when your automated gate ignores it.
Accessible names deserve dedicated behavioral assertions as well as Axe coverage. Use the Playwright missing accessible names tutorial to test buttons, inputs, links, and dialogs through the roles and names exposed to users.
Verify: Run the policy test and confirm it passes. Temporarily change the submit button to <button type="submit" style="color:#aaa;background:#fff">Save profile</button>. Confirm the configured color contrast rule reports the low-contrast button text, then restore the button.
Step 8: Balance Scoped Scans with Page-Level Coverage
Component scans can miss document title, language, landmarks, heading relationships outside the component, and conflicts created when components appear together. Keep a smaller full-page smoke layer alongside the focused suite.
test('fixture page has no page-level detectable violations', async ({ page }) => {
await page.goto('/tests/fixtures/components.html');
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
const { violations } = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(summarizeViolations(violations)).toEqual([]);
});
Run component scans when a feature changes and page scans on critical templates or journeys. This produces fast, owned feedback without abandoning integration coverage. Avoid scanning every route in every browser unless the rendered accessibility tree truly differs. Axe evaluation is primarily DOM and style based, while your Playwright interaction tests should cover browser-specific behavior where it matters.
In CI, retain the Playwright HTML report and attach concise violation JSON for failures. Fail on newly observed violations instead of silently logging them. If an existing issue must be temporarily accepted, track its exact rule and target with an owner and removal date. Broad allowlists tend to become permanent blind spots.
Axe also cannot verify whether status updates are announced at the correct time. Pair these scans with the Playwright live region announcement validation guide for async saves, search results, uploads, and validation summaries.
Verify: Run npx playwright test. You should see the form, dialog, card, fixture, policy, and page-level cases pass. Then remove lang="en" from the fixture. The full-page scan should catch the document language issue even though a scan scoped only to #profile-form may not. Restore the attribute after proving the coverage difference.
Playwright Axe Scan Specific Components Examples: Choosing the Right Scope
Scope should follow ownership and user state. Pick the smallest region that contains the complete component behavior, including its labels, instructions, errors, and controls. Scanning only an input while its label sits outside the included node can create misleading results because the accessible relationship crosses your chosen boundary. Scan the form field wrapper or form instead.
For overlays rendered through React portals, inspect the final DOM. Include the portal root or actual dialog selector after opening it. You can call include() more than once when one logical component is split across DOM roots.
Treat selectors as test interfaces. IDs are excellent for unique landmarks and dialogs. Dedicated attributes work well for reusable components. CSS classes are acceptable when they are stable contracts, not generated styling tokens. XPath and Playwright-only selector engines are not the input expected by Axe's include() API.
Finally, test meaningful states. A form usually needs pristine, invalid, submitting, error, and success coverage. A menu needs closed, open, and keyboard navigation checks. Do not run five Axe scans if the DOM and styling are identical in all five states, but do scan any state that adds content, changes semantics, changes contrast, or moves interactive elements.
Troubleshooting
Problem: Cannot find module '@axe-core/playwright' -> Install both @playwright/test and @axe-core/playwright in the same workspace. Run the test from the package that owns those dependencies, and keep the default import syntax shown above.
Problem: include() matches no useful content -> Confirm the selector in browser developer tools and wait for page.locator(selector) to be visible before analysis. If the component lives in an iframe, enter the correct frame context and review the integration's supported frame behavior instead of assuming a page selector crosses frame boundaries.
Problem: A dialog scan passes without opening the dialog -> Interact with the trigger first and assert toBeVisible(). Scan the rendered open state, then add separate keyboard and focus assertions. Hidden markup is not evidence that the usable state works.
Problem: Violations point outside the component -> Check whether the included node references labels, descriptions, or ownership relationships elsewhere in the DOM. Expand the scope to the complete component boundary and inspect each violation's target and related nodes before deciding it is unrelated.
Problem: Results differ between local and CI runs -> Make fonts, data, viewport, color scheme, and component state deterministic. Wait on a user-visible condition rather than a timeout, pin dependencies through the lockfile, and compare the precise rule IDs and targets.
Problem: A known third-party widget blocks every build -> Add the narrowest possible exclude() with a tracked reason, then create a manual accessibility check for the critical journey. Never exclude the component's entire parent when only one vendor-owned descendant is outside your control.
Common Mistakes and Best Practices
- Do scan after the component reaches the state a user operates.
- Do use stable CSS selectors as an explicit contract for Axe scope.
- Do keep the component's label, instructions, errors, and controls inside the chosen boundary.
- Do print rule IDs, targets, summaries, and help URLs on failure.
- Do pair Axe with role, name, focus, keyboard, and announcement assertions.
- Do not pass a Playwright
Locatordirectly toinclude(). - Do not treat a zero-violation result as proof of accessibility.
- Do not create broad exclusions without owners and review dates.
- Do not rely only on component scans for document-level rules.
- Do not snapshot a large raw result object as a substitute for evaluating current violations.
The strongest suite has several layers: semantic component tests, scoped Axe scans, a modest set of page audits, keyboard interaction tests, and human evaluation with assistive technology. Each layer answers a different question. Together they reduce false confidence.
Where To Go Next
Use the complete Playwright accessibility automation guide to place scoped scans inside a maintainable accessibility strategy. Then deepen the behaviors Axe cannot fully judge:
- Follow the keyboard focus order testing tutorial for Tab order, focus traps, and focus restoration.
- Apply the missing accessible names detection tutorial to buttons, links, inputs, and dialogs.
- Add the live region announcement validation tutorial for dynamic status and error messages.
Start by adding one scoped scan to a high-change component such as a checkout form or modal. Once the failure output is useful and the team responds to it, extract the fixture and expand coverage by component state.
Interview Questions and Answers
Q: How do you scan only one component with Playwright and Axe?
Create new AxeBuilder({ page }), call .include() with a stable CSS selector for the rendered component, and then call .analyze(). Assert that the returned violations array is empty. Wait for the component and move it into the intended state before analysis.
Q: Why use a scoped scan instead of scanning the entire page?
A scoped scan produces focused failures, clearer ownership, and easier component-level regression tests. It also avoids mixing unrelated page issues into a feature test. It must complement full-page checks because document-level and cross-component issues can sit outside the scope.
Q: Can AxeBuilder.include() accept a Playwright Locator?
No. Use the locator for interaction and web-first waiting, but pass a CSS selector string to include(). Keeping those roles separate also makes failures easier to interpret.
Q: When should an Axe scan run for a dialog?
Run it after the user action opens the dialog and after a visibility assertion succeeds. Then separately verify focus entry, keyboard containment where required, Escape behavior, and focus restoration because automated rules cannot prove the whole interaction.
Q: How should a team handle known violations?
Prefer fixing them. If temporary acceptance is necessary, identify the exact rule and target, assign an owner, link a tracked issue, and set a review date. Avoid broad exclusions or global rule disablement.
Q: Does a passing Axe scan prove WCAG conformance?
No. It proves only that the enabled automated rules found no violations in the analyzed state and context. Conformance also requires human judgment, keyboard evaluation, assistive technology testing, and coverage of content and interactions that automation cannot assess.
Conclusion
The practical answer is simple: render the real component state, wait for it, scope AxeBuilder with a stable CSS selector, analyze, and fail on actionable violations. The examples above turn that pattern into maintainable tests for forms, dialogs, repeated widgets, and shared fixtures.
Keep scoped scans close to component ownership, then back them with page-level audits and human-centered interaction tests. Add the first test to a component your team changes often, prove its failure output is useful, and expand from that reliable foundation.
Interview Questions and Answers
How do you scan a specific component with Playwright and Axe?
I wait for the component in its meaningful user state, create AxeBuilder with the page, call include() with a stable CSS selector, and call analyze(). I fail the test when violations is not empty and print concise rule and target details. I keep full-page scans as a separate coverage layer.
What is the main benefit of a scoped accessibility scan?
It ties failures to a component and owner, reduces unrelated noise, and makes state-specific testing clear. This is especially helpful for dialogs, forms, and reusable widgets. The tradeoff is that issues outside the included subtree may be missed.
Can AxeBuilder include() use a Playwright Locator?
No. The include() method takes a CSS selector string. I use Playwright locators for actions and synchronization, then use a stable CSS selector to define the Axe analysis context.
How would you test the accessibility of a modal dialog?
I open it through the user-facing control, assert its accessible role, name, and visibility, and run a scoped Axe scan on the open dialog. I separately test initial focus, Tab behavior, Escape or close behavior, and focus return. Axe alone cannot prove that complete keyboard workflow.
How do you manage Axe exclusions in a test suite?
I use the narrowest selector possible and only for content the team cannot currently change. Every exclusion needs a reason, owner, tracked issue, and review date. I avoid disabling a rule globally because it can hide defects in owned components.
Does zero Axe violations mean a component is WCAG conformant?
No. It means the enabled automated rules found no violations in that state and scope. WCAG evaluation also needs human judgment, keyboard testing, assistive technology checks, meaningful content review, and coverage of all important states.
Frequently Asked Questions
How do I scan a specific component with Axe in Playwright?
Create an AxeBuilder with the Playwright page, call include() with a CSS selector for the component, and call analyze(). Wait until the component is visible and in the state you want to test before analyzing it.
Can I pass a Playwright locator to AxeBuilder include()?
No. AxeBuilder include() expects a CSS selector string, not a Playwright Locator. Use a locator to interact with and wait for the component, then pass its stable CSS selector to Axe.
Should I scan a dialog before or after opening it?
Open the dialog first, assert that it is visible, and scan its open state. Also add separate assertions for focus movement, keyboard behavior, closing, and focus restoration.
Can scoped Axe scans replace full-page accessibility tests?
No. Scoped scans improve focus and ownership, but they can miss document-level and cross-component defects. Keep a smaller set of full-page scans for important templates and journeys.
How do I exclude a third-party widget from a component scan?
Chain exclude() with the narrowest CSS selector that matches only the vendor-owned region. Document why it is excluded, assign an owner or review date, and manually test the essential user journey.
Why does my Axe component test pass when the component is inaccessible?
The component may not be rendered, visible, or in the failing state when analysis runs, or the problem may require human judgment. Verify synchronization and scope, inspect enabled rules, and add keyboard, focus, accessible-name, and assistive technology checks.
Related Guides
- Playwright Assert Realtime Notifications Examples: A Practical Tutorial
- Playwright Test WebSocket Reconnection Logic: A Practical Tutorial
- Selenium BiDi Capture JavaScript Errors Examples: A Practical Tutorial
- Appium 3 Driver Version Management: A Practical Tutorial
- Test AI Agent Loop Termination: A Practical Tutorial
- Test MCP Server Secret Leakage: A Practical Security Tutorial