Skip to content
Dev tool

Auditing your site for accessibility with axe and Playwright

My first axe-core run found 131 serious accessibility violations. How I set up axe and Playwright for a WCAG 2.2 AA audit, what they caught, and what the October re-run on axe 4.14 turned up.

Daniel HallSolutions Architect
17 min read

The first axe-core sweep of this site came back with 131 serious accessibility violations across 24 routes. I'd been trying to bring it up to WCAG 2.2 AA, and that number was a useful bit of humility. 111 of them were a single rule (colour contrast), traced back to one design choice I'd been making in roughly every component.

Getting there took several steps, each with its own challenges. First, I needed some idea of how compliant the site already was, and what would need to change. The work needed a plan for prioritising however many tweaks turned up, and I wanted to be able to re-run the exact same audit to see whether the changes had worked. Ideally, the audit would be lightweight enough to run regularly, so the site stayed compliant as the code changed.

I decided to implement Playwright tests with axe-core, a popular accessibility testing engine. These tests would be re-runnable, and I could add them to my existing GitHub CI pipeline to run alongside the unit tests.

Most of the non-contrast violations were structural, such as half-implemented ARIA and a skipped heading level. A useful proportion of what makes a site fail AA is invisible to a sighted developer testing in their own browser, which is the whole problem with relying on intuition for any of this.

This post is a working playbook to help you get started with auditing your own site: where to set up automated checks, what they catch and what they can't, and the structural-vs-cosmetic split that ends up dictating how you spend your time. The WAI quick reference is still the canonical spec; this is the practical sequence around it.

I'll work from the audit I did on this site. Where it helps, I'll include code snippets.

Updated October 2026

I've amended the code samples, added context based on what WCAG 2.2 and the law now ask for, and re-run the audit on axe-core 4.14 and Playwright 1.64. The October results are in their own section near the end.

What AA actually means

The bar most teams aim for is WCAG 2.2, level AA. The spec is long, but I think it boils down to this:

  • All text contrasts at least 4.5:1 against its background (3:1 for large text and UI components).
  • Every interactive control is reachable, activatable and visible via the keyboard alone.
  • The site works with assistive technology, whether that's a screen reader or a switch device.
  • Anything that moves on its own for more than five seconds can be paused (2.2.2 Pause, Stop, Hide).
  • Forms tell you what they want, and how to fix it when you get it wrong.

Measured by success criteria, automated tools can check roughly a third of this. Measured by volume they do better: Deque's study of real audits found automated tests caught 57% of issues, because the problems they can spot are the common ones. The rest needs a human eye. Or ear.

If you last looked at this under 2.1, WCAG 2.2 added four AA criteria worth knowing about:

  • 2.4.11 Focus Not Obscured (Minimum). A focused element can't be completely hidden behind a sticky header, cookie banner or similar.
  • 2.5.7 Dragging Movements. Anything you can drag needs a single-pointer alternative, such as a button or a tap.
  • 2.5.8 Target Size (Minimum). Click and tap targets are at least 24 by 24 CSS pixels, or have enough space around them.
  • 3.3.8 Accessible Authentication (Minimum). Logging in can't depend on a memory or puzzle test, so don't block paste or password managers.

WCAG 2.2 is also an ISO standard now (ISO/IEC 40500:2025). WCAG 3 is still a Working Draft, and the W3C don't expect it to be finished for a few years yet, so 2.2 AA is the one to aim for.

The European Accessibility Act has applied since 28 June 2025 to a long list of products and services sold in the EU, e-commerce and banking among them. In practice it's assessed against EN 301 549, the European standard that points back at WCAG.

In the UK, public sector websites and apps have to meet WCAG 2.2 AA. Everyone else providing a service to the public is covered by the Equality Act 2010, which expects reasonable adjustments for disabled people. It doesn't name a WCAG level, but 2.2 AA is what anyone checking your site will measure it against.

Phase 1: stand up the automated checks

This is the most valuable step, in my opinion.

Install @axe-core/playwright and @playwright/test:

pnpm add -D @axe-core/playwright @playwright/test
pnpm exec playwright install chromium

Set up a minimal playwright.config.ts. The baseURL matters: it's what lets the spec visit relative paths like /about:

import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./tests/a11y",
  workers: 1,
  use: {
    baseURL: "http://localhost:3000",
  },
  webServer: {
    command: "pnpm dev",
    url: "http://localhost:3000",
    reuseExistingServer: true,
    timeout: 60_000,
  },
});

Define your spec.

import { mkdirSync, writeFileSync } from "node:fs";
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("axe-core sweep across every sitemap route", async ({ page }) => {
  test.setTimeout(180_000);

  // Reads /sitemap.xml so the test picks up new routes as you add them.
  // <loc> holds absolute production URLs, so keep only the path and let
  // baseURL point every visit at the local server.
  const xml = await (await page.request.get("/sitemap.xml")).text();
  const routes = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map((m) => new URL(m[1]).pathname);

  await page.emulateMedia({ reducedMotion: "reduce" });

  const violations: Array<{ route: string; rule: string; impact: string; nodes: number }> = [];

  for (const route of routes) {
    await page.goto(route);
    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"])
      .analyze();

    for (const v of results.violations) {
      if (v.impact === "critical" || v.impact === "serious") {
        violations.push({ route, rule: v.id, impact: v.impact, nodes: v.nodes.length });
      }
    }
  }

  // The report CI uploads. Written before the assertion so it exists on a failing run too.
  mkdirSync("docs", { recursive: true });
  writeFileSync("docs/a11y-baseline.json", `${JSON.stringify(violations, null, 2)}\n`);

  expect(violations, JSON.stringify(violations.slice(0, 5), null, 2)).toEqual([]);
});

A few notes that matter:

  • List every tag you mean. axe tags each rule with the WCAG version that introduced it, so wcag2a and wcag2aa only cover 2.0. Leave out wcag21a and wcag21aa and rules like autocomplete-valid never run. My original version of this snippet made exactly that mistake. best-practice adds Deque's own rules on top, which is where some of my first findings came from.
  • Emulate reduced motion in the page context. If you use any kind of fade-in animation that starts at opacity: 0, axe will measure the mid-fade composite colour and accuse perfectly fine text of failing contrast. page.emulateMedia({ reducedMotion: "reduce" }) makes the audit deterministic. It also nudges you to make sure your animations honour the preference for real users. WCAG covers that in 2.3.3 Animation from Interactions, although that one is AAA rather than AA.
  • Use workers: 1. axe is more useful when routes are walked serially. It's easier to map a violation back to a route in the failure summary, and the dev server doesn't get hammered.

My first run, back in June, came back with 131 serious and 6 moderate violations across 24 routes. 111 of the 131 were color-contrast. The rest were spread across nested-interactive, dlitem, definition-list, landmark-complementary-is-top-level and heading-order. Five rules, an hour or two of work. Then the contrast pass, which was its own thing.

Phase 2: keyboard navigation

axe doesn't really audit the keyboard. It checks that focusable elements exist, not that the tab order makes sense or that opening a modal moves focus into it.

Most of that is a human-with-a-keyboard job. But you can still write Playwright tests for the bits that are easy to break and easy to assert on.

A skip-link smoke test as an example:

test("skip link is the first focusable element", async ({ page }) => {
  await page.goto("/");
  await page.keyboard.press("Tab");

  const focused = await page.evaluate(() => document.activeElement?.textContent);
  expect(focused).toMatch(/skip to/i);

  await page.keyboard.press("Enter");
  const hash = new URL(page.url()).hash;
  expect(hash).toBe("#main");
});

The non-obvious one I noticed was that client-side route changes don't reset focus. After you navigate from /writing to /writing/<slug>, your next Tab press resumes from wherever the previously focused link sat in the DOM of the old page, which on the new page is usually somewhere in the middle of the content. A fresh page load doesn't do this. It's caused by the site behaving as an SPA.

I ended up implementing a hidden sentinel that the layout focuses on every pathname change, skipping the first render so a fresh page load keeps its normal focus:

"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";

export function RouteFocusReset() {
  const pathname = usePathname();
  const firstRender = useRef(true);

  useEffect(() => {
    // Leave focus alone on a fresh page load; only reset on navigation.
    if (firstRender.current) {
      firstRender.current = false;
      return;
    }
    document.getElementById("route-focus-anchor")?.focus({ preventScroll: true });
  }, [pathname]);

  return null;
}

The layout has <div id="route-focus-anchor" tabIndex={-1} className="sr-only" /> before the skip link. The next Tab after a route change now lands on Skip to content, matching a fresh page load. I'd never have caught this without explicitly testing for it, because visually nothing changes.

The other one worth a programmatic test is the mobile-nav focus trap. If you use a headless dialog primitive (Radix, Headless UI), the trap is usually there, but the cost of confirming it didn't regress when you styled it is roughly zero:

test("mobile-nav traps focus", async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 640 });
  await page.goto("/");
  await page.getByRole("button", { name: /menu/i }).click();

  for (let i = 0; i < 6; i++) {
    await page.keyboard.press("Tab");
  }

  const inDialog = await page.evaluate(() =>
    Boolean(document.activeElement?.closest('[role="dialog"]')),
  );
  expect(inDialog).toBe(true);
});

Phase 3: the structural fixes

Five rules made up the non-contrast violations on my site. Each one took minutes to fix once I'd seen it. Together they account for most of what axe catches that you wouldn't otherwise notice.

landmark-complementary-is-top-level. I'd wrapped the article ToC in <aside>. <aside> has an implicit role="complementary", which expects to be outside any other landmark, but it sat inside <main>. Worse, my ToC component already wrapped itself in its own <nav> landmark, so my first attempted fix (swap to <nav aria-label="Table of contents">) just turned the violation into landmark-unique. The fix was a plain <div> wrapper, because the inner component already provided the landmark. axe-core deprecated this rule in 4.12, so newer versions won't flag it, but the duplicate landmark problem behind it hasn't gone anywhere.

Lesson

Before adding a landmark to a wrapper, check whether the child already provides one. Duplicate-named landmarks read worse to a screen-reader user than no wrapper at all.

heading-order. The /writing page rendered <h1>Writing</h1> followed directly by an <h3> per card, skipping <h2>. Visually it looked fine; the cards read as cards. To a screen-reader user, the heading outline jumps a level. I added a visually hidden <h2 className="sr-only">All posts</h2> between the page header and the grid. Sighted users never see it, but it gives a screen reader the missing level, and here it genuinely helps.

nested-interactive. I had a grid that rendered <ul role="listbox"><li role="option"><button>…</button></li></ul>. The full WAI-ARIA listbox pattern expects the option to be the focusable thing (via roving tabindex), not to contain a <button> that's separately focusable. The fix was simply to remove the wrong ARIA. A plain <ul> of <button>s is keyboard-accessible by default. I added aria-pressed to the buttons so the active state still announces.

Lesson

Missing ARIA is often safer than half-implemented ARIA. The native <button> is already accessible; decorating it with role="option" made things worse.

definition-list and dlitem. My epoch-converter tool had an extra wrapper between the <dl> and its <dt> / <dd> children. <dl> doesn't allow that. I just had to flatten the structure, with no visible change.

Phase 4: colour contrast

A huge 111 of my 131 violations were contrast failures, and the only reason that number wasn't higher is that I happen to use a fairly limited palette.

The site's brand colour is #ff3d00. Against the off-white page background at the time (#f0ede5), that's a contrast ratio of 3.03:1. AA requires 4.5:1 for body text, and every active filter label and orange hover state was falling short.

My first instinct was to darken the orange brand colour. I tested candidate shades by exact luminance against the page background, the surface background and white-on-button, and #c92e00 was the smallest shift that cleared 4.5:1 on all three at once. I swapped the CSS variable and pnpm a11y went from 111 violations to 28. Two more passes on opacity modifiers and tinted backgrounds, and the count hit zero.

This was the technically correct fix, and the brand still looked like itself. But side by side, the bright orange just looked better. So I undid it.

I ended up keeping the bright #ff3d00 brand orange, but stopped using it for text on light backgrounds. The contrast maths only cares when the orange is text, not when it's a background, a border or a glow. The split rule I now apply:

  • Backgrounds, borders, glows, decorative dots, icons and the underlines under body links are all fine being orange, because none of them are text.
  • Orange text on light backgrounds, including hover and active states, fails. Use a border, a heavier weight or an underline on the hover or active state instead, and keep the text black.
  • Buttons with the orange background: text colour switches to black, not white. Black on #ff3d00 is about 5.9:1; white on #ff3d00 is 3.5:1 (close, but not enough).

Replacements such as group-hover:text-accent on a card title became group-hover:underline. Active filter tabs lost the orange text and kept just the accent bottom border. Inline article links became black text with an orange underline, rather than orange text. None of these looked worse, and many looked much better.

The one change that did feel like a compromise was the button text colour. Black on bright orange is visually heavier than white on bright orange, but it passes AA without me having to dilute the brand.

By the end of June, axe-core reported zero contrast violations across every route.

Phase 5: the manual sweeps

Past this point, the rest needs a person with a keyboard and a screen reader.

Screen reader walkthrough. macOS VoiceOver (Cmd+F5 to toggle) plus Safari is the supported pairing on Apple's platform. The VoiceOver rotor (Ctrl+Option+U) gives you headings, links, form controls and landmarks for each page. It's the fastest triage tool you have. Walk every route from the top with Ctrl+Option+A and listen for the bits that don't sound right. Do dynamic regions (form results, tool outputs) announce when they change? Do buttons describe what they'll do, not what they look like? Small things, too: does "danieljh.dev" read as the brand or as "danieljh dot dev"?

Reduced motion. Turn on macOS System Settings → Accessibility → Motion → Reduce motion (or emulate it in browser dev tools), then check every animation. Animations should skip to the end state, not slow down. MDN's prefers-reduced-motion page covers the media query itself. The global CSS that handles most of this:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

For Motion (formerly Framer Motion), wrap the app in <MotionConfig reducedMotion="user">. For GSAP, gate any animation timeline on matchMedia("(prefers-reduced-motion: reduce)").matches.

The component-level mistake I had to fix was a fade-in wrapper that started at opacity: 0 and waited for an intersection observer. With reduced motion on, the observer never fired and the content stayed invisible. useReducedMotion() from Motion fixed it: when it's set, skip the fade and render the content in its final state immediately.

Keyboard, eyes on. Even after the programmatic tests, navigate every route with Tab alone and just look at where the focus ring goes. The most common bug I find here is that the focus ring exists but it's invisible against that particular background. I ended up implementing a global rule that draws both a foreground outline and a background-coloured halo so it's visible on every surface, dark mode included:

:focus-visible {
  outline: 2px solid var(--foreground);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--background);
}

The halo trick is what makes it work on accent buttons and dark surfaces alike. The foreground outline says "this is focused"; the background-coloured halo gives the outline a stripe of breathing room against whatever sits underneath, regardless of colour.

Wiring it into CI

As I said at the start, I wanted this in my GitHub Actions pipeline so I'd stay on top of accessibility as the site changes.

a11y:
  runs-on: ubuntu-latest
  timeout-minutes: 10
  steps:
    - uses: actions/checkout@v7
    - uses: pnpm/action-setup@v6
      with: { version: 10 }
    - uses: actions/setup-node@v7
      with: { node-version: 22, cache: pnpm }
    - run: pnpm install --frozen-lockfile
    - run: pnpm exec playwright install --with-deps chromium

    - run: pnpm a11y

    - if: always()
      uses: actions/upload-artifact@v7
      with:
        name: a11y-baseline
        path: docs/a11y-baseline.json

A few things to note:

  • Don't bother caching the Chromium download. I did at first, but Playwright's CI docs advise against it: restoring the cache takes about as long as downloading the browser, and on Linux the system dependencies can't be cached anyway.
  • Upload the violations report as an artifact even on failure, so a reviewer can diff it against the main-branch baseline on a failing PR.
  • My CI runs without the Sanity environment variables, so its sitemap only lists the static routes. The post pages only get audited when I run the sweep locally against real content.

The running time on a small site like mine is under 90 seconds, roughly the same as the lint and typecheck job it runs in parallel with.

Re-running it on axe-core 4.14

In October I bumped axe-core to 4.14 and Playwright to 1.64 and ran the same sweep. The site has grown to 29 routes since June, and a run that had been clean came back with 249 serious violations. 247 of them were one rule, label-content-name-mismatch, which 4.14 now runs by default after years as an experimental rule. It checks WCAG 2.5.3 Label in Name: if a control shows text, its accessible name has to include that text, so someone using voice control can say what they see.

The worst offender is my own wordmark. The header and footer links show "danieljh.dev" but carry aria-label="Daniel Hall — home", so they fail on every page. The radar's technology tiles account for most of the rest (147 nodes on that page alone), along with the tools' "Jump to another tool" switcher and a handful of other labelled buttons. None of them are hard to fix. Either the label starts with the visible text, or the aria-label goes and the text does the job.

The other two were the comment lines in this post's own spec snippet, which the code theme renders at 3.88:1 on the dark background. None of these are fixed at the time of writing.

What I'd do differently

If I'd done this audit earlier, I'd have got design decisions such as colour contrast right from the start. Retrofitting it across a dozen components was a bit of a pain.

The parts of WCAG that automation can't check are also where I learned the most about how sighted developers think about their own work. If you build web UI for a living, I'd encourage you to spend an hour with VoiceOver on, on a site you didn't build, just to feel what an inaccessible page is like from the other side.

This site isn't finished. I haven't redone the manual phases since June, so the per-route VoiceOver walk and the visual check with Reduce motion switched on are both still owed, alongside the October label fixes. Accessibility is one of those things where "done" keeps moving as the site changes, and I'll come back and update this post when I've caught up.

Further reading

Related posts