This guide shows you how to build a repeatable keyboard accessibility audit using axe-core 4.9 and Playwright 1.48. You will identify WCAG 2.2 violations, generate a structured report, and apply targeted fixes. Expect the full setup to take around two hours, with subsequent audits running in under ten minutes.
What You'll Build
- A Playwright test suite that runs axe-core against every page in your app
- A JSON violation report grouped by impact level (critical, serious, moderate, minor)
- A keyboard-focus traversal test that catches missing focus rings and focus traps
- A reusable CI step that blocks PRs when new violations appear
Prerequisites
- Node.js 20 or later and pnpm 9 or later installed
- A web app accessible at a local or staging URL
- Basic familiarity with Playwright tests
- A GitHub Actions workflow file (optional, for the CI step)
Step 1: Install axe-core and the Playwright Integration
The axe-core library is maintained by Deque Systems and is the same engine used by browser extensions like axe DevTools. The @axe-core/playwright package wraps it for Playwright's page context.
Run this in your project root:
pnpm add -D @axe-core/playwright axe-core playwright @playwright/test
pnpm exec playwright install --with-deps chromium
Expected result: Playwright downloads a Chromium binary and your devDependencies now include axe-core 4.9 and @axe-core/playwright 4.9.
Common pitfall: If you already have Playwright installed globally, version mismatches between the global binary and the local package cause test failures. Always use pnpm exec playwright to invoke the local version.
Step 2: Write Your First Axe Audit Test
Create a file at tests/a11y/audit.spec.ts. This test visits each URL in your app and runs the full axe ruleset.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = [
{ name: 'Home', path: '/' },
{ name: 'Pricing', path: '/pricing' },
{ name: 'Login', path: '/login' },
{ name: 'Dashboard', path: '/dashboard' },
];
for (const { name, path } of pages) {
test(`${name} page has no WCAG 2.2 AA violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
}
Why specify WCAG tags rather than running all rules?
axe-core ships with experimental rules that are not yet part of any published standard. Running everything creates noise that obscures real violations. The tags wcag2a, wcag2aa, and wcag22aa target the WCAG 2.2 Level AA criteria, which is the legal baseline in Australia (under the Disability Discrimination Act), Canada (AODA), Singapore (iGovt guidelines), and the US (ADA and Section 508).
Step 3: Generate a Structured Violation Report
A failing test tells you something is wrong. A structured report tells you exactly what, where, and how severe. Add a reporter utility at tests/a11y/reporter.ts:
import fs from 'fs';
import path from 'path';
import type { AxeResults } from 'axe-core';
export function writeReport(pageName: string, results: AxeResults) {
const outDir = path.resolve('a11y-reports');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const grouped = results.violations.reduce(
(acc, v) => {
acc[v.impact ?? 'minor'] ??= [];
acc[v.impact ?? 'minor'].push({
id: v.id,
description: v.description,
helpUrl: v.helpUrl,
nodes: v.nodes.map((n) => n.html),
});
return acc;
},
{} as Record
);
const report = {
page: pageName,
timestamp: new Date().toISOString(),
totalViolations: results.violations.length,
grouped,
};
const fileName = `${pageName.toLowerCase().replace(/\s+/g, '-')}.json`;
fs.writeFileSync(path.join(outDir, fileName), JSON.stringify(report, null, 2));
}
Update your test file to call writeReport after each audit, even when violations exist:
import { writeReport } from './reporter';
// Inside the test, after .analyze():
writeReport(name, results);
Expected result: After a test run, your project contains an a11y-reports/ directory with one JSON file per page. Each file lists violations grouped under critical, serious, moderate, and minor keys.
Step 4: Add a Keyboard Focus Traversal Test
axe-core catches many violations, but it cannot simulate a real user pressing Tab. This step adds a traversal test that records every focused element and flags two common issues: missing visible focus rings, and focus traps that prevent Tab from advancing.
test('Login page has a complete and visible focus order', async ({ page }) => {
await page.goto('/login');
const focusedElements: string[] = [];
let previousElement = '';
let trapCount = 0;
// Tab through up to 30 elements to avoid infinite loops
for (let i = 0; i < 30; i++) {
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
return {
tag: el.tagName,
role: el.getAttribute('role'),
label: el.getAttribute('aria-label') ?? el.textContent?.slice(0, 40),
hasFocusStyle: window.getComputedStyle(el).outlineWidth !== '0px',
};
});
if (!focused) break;
const key = `${focused.tag}:${focused.label}`;
if (key === previousElement) {
trapCount++;
if (trapCount > 2) {
throw new Error(`Focus trap detected on: ${key}`);
}
}
if (!focused.hasFocusStyle) {
console.warn(`Missing focus ring on: ${key}`);
}
focusedElements.push(key);
previousElement = key;
}
expect(focusedElements.length).toBeGreaterThan(2);
});
What if this test flags elements with custom focus styles?
Some teams replace the browser's default outline with a box-shadow or border. The outlineWidth check above will produce false positives in that case. Replace the check with a function that also reads boxShadow and border to cover custom implementations. The important rule is that the focus indicator meets a 3:1 contrast ratio against the adjacent colour, per WCAG 2.2 Success Criterion 2.4.11.
Step 5: Add a Playwright Configuration for Your Staging URL
Create or update playwright.config.ts so the audit suite always runs against a real server:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/a11y',
use: {
baseURL: process.env.AUDIT_URL ?? 'http://localhost:3000',
headless: true,
},
reporter: [['list'], ['json', { outputFile: 'a11y-reports/playwright-results.json' }]],
});
Set AUDIT_URL to your staging domain in CI. Leave it unset for local runs against the dev server.
Step 6: Add a CI Step That Blocks Failing PRs
Paste this job into your .github/workflows/ci.yml file:
a11y-audit:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- name: Run accessibility audit
run: pnpm exec playwright test
env:
AUDIT_URL: ${{ secrets.STAGING_URL }}
- name: Upload violation reports
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-reports
path: a11y-reports/
The if: always() clause uploads reports whether the job passes or fails. This gives your team the full report even when a PR is blocked.
Pro tip: Add a baseline snapshot of known violations on your main branch. Then configure the CI step to fail only when new violations appear, not pre-existing ones. This prevents the audit from blocking unrelated PRs while the backlog is being cleared.
Step 7: Fix the Most Common Violations
How do you fix missing form labels?
axe-core will flag inputs without associated labels as a critical violation under rule label. Add a visible <label> element or an aria-label attribute:
<!-- Before -->
<input type="email" placeholder="Enter email" />
<!-- After -->
<label for="email">Email address</label>
<input id="email" type="email" placeholder="Enter email" />
How do you fix insufficient colour contrast?
The axe rule color-contrast checks that text meets a 4.5:1 ratio against its background (3:1 for large text, per WCAG 2.2 SC 1.4.3). Use the Deque colour contrast analyser or the Figma A11y plugin to find a passing alternative before updating your design tokens.
How do you fix missing skip-navigation links?
Users who rely on keyboard navigation must be able to skip repeated navigation menus. Add this as the first focusable element in your layout:
<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>
<main id="main-content">...</main>
The Tailwind classes sr-only and focus:not-sr-only keep the link visually hidden until it receives focus.
Teams at Lenka Studio run this audit process at the start of every client QA cycle. Catching violations before handoff reduces back-and-forth by a measurable margin, typically avoiding two or three rounds of revision on complex flows.
Step 8: Schedule Weekly Regression Audits
Content updates, third-party script changes, and A/B tests all introduce new violations. A weekly scheduled run catches regressions before they reach production.
Add a schedule trigger to the same workflow file:
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 3 * * 1' # Every Monday at 03:00 UTC
Send the artifact to a shared Slack channel using the slack-github-action so the relevant team member sees it on Monday morning without checking GitHub manually.
Frequently Asked Questions
Does axe-core catch all WCAG 2.2 violations automatically?
No. axe-core catches around 57% of WCAG issues automatically, according to Deque's own research. The remaining violations, such as logical reading order and meaningful link text in context, require manual review. Use this audit as your baseline, then supplement with manual keyboard testing and screen reader checks.
Can I run this audit on a site that requires authentication?
Yes. Use Playwright's storageState option to save a logged-in browser state to a file, then load it at the start of each test. The Playwright documentation covers the full page.context().storageState() API. This approach works for session-cookie and JWT-based authentication.
How is axe-core different from Lighthouse accessibility scoring?
Lighthouse uses a subset of axe-core rules to produce a score from 0 to 100, but it omits several serious rules and does not support WCAG 2.2. axe-core with the wcag22aa tag set is more complete and maps violations directly to success criteria, which matters for legal compliance reporting.
What if the test fails on a third-party widget I cannot change?
Use axe-core's .exclude() method to exclude a CSS selector from the scan. Document the exclusion and the reason in a comment. Excluding inaccessible third-party components is a legitimate short-term decision, but log it as a known risk and revisit when the vendor updates their widget.
Does this work on React, Vue, and server-rendered apps?
Yes. Playwright audits the rendered DOM in a real browser, so the underlying framework does not matter. The only requirement is that the page is reachable at a URL. This makes it equally effective for Next.js apps, Nuxt sites, Webflow pages, and plain HTML files.
Next Steps
With your audit running in CI, the next layer of improvement is manual testing with a screen reader. Use NVDA on Windows or VoiceOver on macOS to walk through your most critical flows. Focus on forms, modal dialogs, and data tables, since these are where axe-core's automated coverage is weakest.
You may also want to build an accessible component library. If your team is working from a shared design system, establishing accessibility acceptance criteria at the component level prevents violations from entering production in the first place. The process mirrors the kind of design-to-development workflow the team at Lenka Studio applies when building accessible interfaces for SaaS products and e-commerce platforms across Australia and Singapore.
If you want support setting up a full accessibility testing workflow, or integrating it into a larger QA process, get in touch with the Lenka Studio team. We can help you scope the work and prioritise the fixes that will have the most impact for your users.




