skills/writing-accessibility-tests/SKILL.md
Use this skill to write Playwright accessibility tests using the two-layer strategy (axe-core scans + targeted assertions). Triggers when adding accessibility test coverage, reviewing test gaps, writing axe scans, or creating Playwright assertions for accessible names, landmarks, ARIA states, focus management, or contrast.
npx skillsauth add mattobee/skills writing-accessibility-testsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Write Playwright tests that verify WCAG accessibility compliance using a two-layer strategy: automated axe-core scans for broad coverage, plus targeted Playwright assertions for things axe cannot catch.
Every page or feature needs both layers:
Automated scans catch structural violations at scale: missing alt text, duplicate IDs, basic colour contrast, missing form labels, invalid ARIA attributes, missing lang attribute, landmark violations, heading level skips.
Targeted assertions catch what axe misses: accessible names on custom components, landmark presence, heading hierarchy, aria-current state, aria-live region configuration, aria-invalid state management, aria-describedby associations, focus management after interactions, custom property contrast, and shadow DOM internals.
Do not duplicate what axe already catches. Layer 2 exists for the gaps.
Install @axe-core/playwright as a dev dependency:
npm install -D @axe-core/playwright
Create a reusable scan function scoped to WCAG 2.2 Level AA:
import AxeBuilder from '@axe-core/playwright';
import type { Page } from '@playwright/test';
async function runAxeScan(page: Page) {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
return results;
}
Assert with:
expect(results.violations).toEqual([]);
Using toEqual([]) instead of toHaveLength(0) produces better failure messages — the full violation details appear in the test output.
Excluding elements: If third-party iframes or embedded widgets produce false positives, exclude them:
new AxeBuilder({ page }).exclude('iframe').withTags([...]).analyze();
Custom rules: Disable specific rules only when there is a documented justification, not to suppress inconvenient findings:
new AxeBuilder({ page }).disableRules(['specific-rule-id']).analyze();
When axe finds violations, raw output is hard to read. Use the formatter in scripts/format-violations.ts to produce structured failure messages:
import { formatViolations } from './scripts/format-violations';
expect(
results.violations,
`Accessibility violations found:\n\n${formatViolations(results.violations)}`
).toEqual([]);
Adapt the import path to the project's test helper location. The script is a reference implementation — copy it into the project's test utilities.
For projects with many axe scans, a Playwright fixture reduces boilerplate:
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
type A11yFixtures = {
makeAxeBuilder: () => AxeBuilder;
expectNoAxeViolations: () => Promise<void>;
};
export const test = base.extend<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page }).withTags([
'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa',
])
);
},
expectNoAxeViolations: async ({ page }, use) => {
await use(async () => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
},
});
export { expect };
Tests then use:
import { test } from './fixtures/base';
test('page has no accessibility violations', async ({ expectNoAxeViolations }) => {
await expectNoAxeViolations();
});
test.describe('Page Name accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/route');
// Wait for meaningful content, not networkidle
await page.getByRole('heading', { name: 'Page Title' }).waitFor();
});
// Layer 1: axe scan
test('has no WCAG 2.2 AA violations', async ({ page }) => {
const results = await runAxeScan(page);
expect(results.violations).toEqual([]);
});
// Layer 2: targeted assertions
test('form fields have correct accessible names', async ({ page }) => {
await expect(
page.getByRole('textbox', { name: 'Email' })
).toHaveAccessibleName('Email');
});
});
Conventions:
test.describe() blocks per page or featurebeforeEach with navigation and a content wait'filter controls have accessible names', not 'a11y check'Wait for a visible, meaningful element rather than networkidle:
// Good: waits for actual content
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
// Avoid: flaky, doesn't guarantee content is rendered
await page.waitForLoadState('networkidle');
For pages with dynamic data, wait for a specific data-dependent element:
await page.waitForSelector('#event-list');
await expect(
page.getByRole('textbox', { name: 'Email' })
).toHaveAccessibleName('Email');
await expect(
page.getByRole('button', { name: 'Save profile' })
).toHaveAccessibleName('Save profile');
await expect(
page.getByRole('textbox', { name: 'Email' })
).toHaveAccessibleDescription('Please enter a valid email address');
// Invalid field
await expect(emailInput).toHaveAttribute('aria-invalid', 'true');
// Expanded disclosure
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
// Current navigation item
await expect(navLink).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByRole('banner')).toBeVisible();
await expect(page.getByRole('contentinfo')).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
const h1 = page.getByRole('heading', { level: 1 });
await expect(h1).toBeVisible();
await expect(h1).toHaveAccessibleName('Page Title');
const h1Count = await page.getByRole('heading', { level: 1 }).count();
expect(h1Count).toBe(1);
await expect(page.locator('.filter-count')).toHaveAttribute('aria-live', 'polite');
await expect(page.locator('.filter-count')).toHaveAttribute('aria-atomic', 'true');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole('heading', { name: 'Confirm deletion' })
).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible();
// For destructive confirmations
const alertDialog = page.getByRole('alertdialog');
await expect(alertDialog).toBeVisible();
// Submit empty form
await page.getByRole('button', { name: 'Submit' }).click();
// Field enters error state
const emailInput = page.getByRole('textbox', { name: 'Email' });
await expect(emailInput).toHaveAttribute('aria-invalid', 'true');
await expect(emailInput).toHaveAccessibleDescription('Email is required');
// Error announced via alert
await expect(page.locator('#email-error[role="alert"]')).toContainText(
'Email is required'
);
const currentLink = page.locator('nav a[href="/current-page"]');
await expect(currentLink).toHaveAttribute('aria-current', 'page');
Scan pages in both light and dark themes to catch contrast regressions:
for (const colorScheme of ['light', 'dark'] as const) {
test(`has no WCAG violations in ${colorScheme} mode`, async ({ page }) => {
await page.emulateMedia({ colorScheme });
await page.goto('/route');
const results = await runAxeScan(page);
expect(results.violations).toEqual([]);
});
}
If the theme is stored in localStorage, also set it there:
await page.evaluate((scheme) => {
localStorage.setItem('theme-preference', scheme);
}, colorScheme);
await page.reload();
axe cannot evaluate contrast for elements styled with CSS custom property chains. For these, compute contrast manually in the test. Read scripts/contrast-helpers.ts for the helper functions (parseColor, luminance, contrastRatio). Copy them into the project's test utilities, then use:
const fgColor = await element.evaluate((el) => getComputedStyle(el).color);
const bgColor = await container.evaluate((el) => getComputedStyle(el).backgroundColor);
const ratio = contrastRatio(fgColor, bgColor);
expect(ratio, `Contrast ratio is ${ratio.toFixed(2)}:1, expected at least 4.5:1`).toBeGreaterThanOrEqual(4.5);
Playwright's toHaveAccessibleName() cannot pierce shadow DOM. For web components with shadow encapsulation, assert on the host element's attributes instead:
// Button with visible slotted text
await expect(page.locator('#my-button')).toContainText('Button Label');
// Icon-only button — check the icon's label attribute
await expect(page.locator('#my-button icon-element')).toHaveAttribute('label', /.+/);
// Dialog/drawer — check the label attribute on the host
await expect(page.locator('#my-dialog')).toHaveAttribute('label', 'Dialog Name');
// Switch/select — check label attribute
await expect(page.locator('#my-switch')).toHaveAttribute('label', /.+/);
The axe scan validates the computed accessible name — these assertions verify the attributes that produce it are present and non-empty.
For apps with many routes, scan all of them systematically:
interface RouteConfig {
name: string;
path: string;
waitFor: string;
}
const routes: RouteConfig[] = [
{ name: 'dashboard', path: '/dashboard', waitFor: 'Dashboard' },
{ name: 'settings', path: '/settings', waitFor: 'Settings' },
{ name: 'profile', path: '/profile', waitFor: 'Profile' },
];
for (const route of routes) {
test(`${route.name} has no accessibility violations`, async ({ page }) => {
await page.goto(route.path);
await page.getByRole('heading', { name: route.waitFor }).waitFor();
const results = await runAxeScan(page);
expect(results.violations).toEqual([]);
});
}
This pairs well with theme scanning — nest the route loop inside the colour scheme loop for full coverage.
After writing or modifying test files, run them and verify the results before reporting:
npx playwright test <file>Do not report results from tests that have not been executed. A test that looks correct but has a typo in a selector or an incorrect accessible name string produces false confidence.
networkidle is flaky and does not guarantee the DOM is ready for axe to scan. Wait for a specific visible element instead.toEqual([]) over toHaveLength(0) for violations. toEqual prints the full violation array on failure; toHaveLength only says "expected 0, got 3" with no details.aria-errormessage has inconsistent AT support. Use aria-describedby for error association and assert with toHaveAccessibleDescription. This has broader assistive technology support.role="textbox". Use page.locator('input[type="password"]') instead of page.getByRole('textbox') to target password inputs.toHaveAccessibleName() reads the accessibility tree, which cannot always pierce shadow boundaries. For web components, check the host element's label, aria-label, or slotted text content directly.<dialog> with showModal() render in the top layer. The host element may have height: 0, making isVisible() unreliable. Check the open attribute instead.rgb()/rgba() strings. getComputedStyle returns computed values, which are always rgb()/rgba() in modern browsers, but verify the parsing works in the project's browser targets.page.getByRole('banner').getByRole('link', { name: 'GitHub' }) instead of page.getByRole('link', { name: 'GitHub' }).tools
Use this skill to work through review feedback on a pull request — read the inline review comments, assess each one's validity, make the code changes that are warranted, and reply to every thread with a one-line explanation of what was done (or why it was declined). Triggers when the user asks to address PR feedback, respond to reviewers, work through review comments, handle a code review, action the comments on a PR, or asks "what do the reviewers want changed?" Also triggers when resuming work on a PR that has open review threads.
tools
Use this skill to suggest prioritised next steps for a project. Triggers when the user asks what to work on next, wants to resume after a break, or needs help prioritising a backlog.
development
Use this skill to review implemented UI code for WCAG accessibility compliance. Triggers when reviewing components, pages, or templates for accessibility, auditing a feature after implementation, or answering questions about accessible patterns, ARIA, keyboard navigation, or screen reader support.
testing
Use this skill to prioritise a set of accessibility issues for remediation based on severity, user impact, and effort. Triggers when triaging an accessibility backlog, deciding what to fix first after an audit, planning an accessibility sprint, or asking which accessibility issues matter most.