"We'll do an accessibility pass before launch" usually means it never happens, or happens once. Automated checks won't catch everything, but they catch a lot, cost nothing, and run on every change. Here's the setup I use: Playwright drives a real browser, axe-core audits each page against WCAG 2.2 AA, and GitHub Actions runs it all on every push and pull request.
What it costs
Nothing. Playwright and @axe-core/playwright are open source. GitHub Actions is unlimited for public repos and includes 2,000 minutes a month for private ones; a full run of this suite takes about three minutes.
The test: every page, both themes
// tests/e2e/a11y.spec.ts
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const PAGES = ['/', '/about/', '/work/', '/contact/', '/notes/'];
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'];
// Nothing mid-animation while contrast is measured.
test.use({ reducedMotion: 'reduce' });
for (const theme of ['light', 'dark']) {
for (const path of PAGES) {
test(`${path} has no violations (${theme})`, async ({ page }) => {
await page.addInitScript((t) => localStorage.setItem('theme', t), theme);
await page.goto(path);
const { violations } = await new AxeBuilder({ page }).withTags(TAGS).analyze();
const summary = violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`);
expect(summary, summary.join('\n')).toEqual([]);
});
}
}Three details matter:
- Test both themes. Dark mode is where contrast bugs hide.
- Emulate reduced motion. Otherwise axe can measure an element halfway through a fade-in and report contrast failures that users never see.
- Print the selectors. A failure should say which element, not just which rule.
Run it on desktop and mobile viewports as two Playwright projects; mobile layouts fail differently.
Test the build, not the dev server
Point Playwright at the production build served as static files, so you test what ships:
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
webServer: {
command: 'python3 -m http.server 4400 --directory dist',
url: 'http://localhost:4400/',
},
use: { baseURL: 'http://localhost:4400' },
});The workflow
# .github/workflows/ci.yml
name: CI
on:
push: { branches: [main] }
pull_request:
permissions: { contents: read }
jobs:
site:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec playwright test
- if: failure()
uses: actions/upload-artifact@v4
with: { name: playwright-report, path: playwright-report/ }Keep secrets out of it: if the build reads public config (a CMS project ID, say), store it as a repository variable, not a secret, and scope it to the job that needs it.
What it actually caught
My own manual axe run had come back clean. The CI suite, which waits for interactive islands to load, found real problems:
- Card links whose label didn't match their text (WCAG 2.5.3). Each project card was one big link with an
aria-labellike "CAGPT: view details", while its visible text was the whole card. The fix was the standard accessible-card pattern: the title is the link, and its::afterstretches over the card so the whole card stays clickable. - Initials inside labelled buttons. An avatar pager showed initials as text inside buttons labelled "Show testimonial from …". Drawing the initials with CSS
content: attr(data-initials)kept the look and removed the mismatch. - Faded inactive states failing contrast.
opacity: 0.6on inactive avatars dropped the text below 4.5:1. Greyscale instead of opacity fixed it. - Animated text that screen readers couldn't read. GSAP's SplitText with
aria: 'auto'puts anaria-labelon whatever it splits, but ARIA forbids labels on plain<p>and<span>. A small helper now labels headings and links, and gives anything else a visually hidden copy of the text.
Beyond axe
Automated rules cover maybe a third to a half of WCAG. Add interaction tests for what axe can't see:
test('skip link is the first tab stop', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Skip to content' })).toBeFocused();
});
test('menu closes with Escape and returns focus', async ({ page }) => {
await page.goto('/');
const open = page.locator('[data-nav-toggle="open"]');
await open.click();
await page.keyboard.press('Escape');
await expect(open).toBeFocused();
});Then keep a person in the loop for what no test sees: does the page make sense with a screen reader, is the focus order logical, does it work at 200% zoom. The CI catches regressions; you still own the experience.