Skip to main content
← Back to blog

Axe-core as a spell-check for accessibility

A 200KB library that runs on every PR and catches 60% of a11y issues before merge. Here's what it catches, what it doesn't, and why it matters anyway.

Accessibility is still missing from many teams' workflow. We know about it. We know it matters. And still, Monday rolls around, a commit ships with an unlabelled input, a button without aria-label, a 3.1:1 contrast ratio, and nobody notices until a screen-reader user emails.

The shift at Agentikas came the day we treated accessibility as a tooling problem, not a culture problem. If a linter flags a typo'd let, it can also flag an <img> without alt. The library that does this is axe-core, and we run it on every PR.

What axe-core is, in one sentence

A library from Deque Systems, open-source for a decade, that takes a rendered page and returns a structured list of WCAG violations. Three severity categories — critical, serious, moderate — with the violated rule, the exact CSS selector of the offending node, and a link to docs on how to fix it.

{
  "violations": [
    {
      "id": "image-alt",
      "impact": "critical",
      "description": "Images must have alternate text",
      "nodes": [
        { "target": ["#hero-image"], "html": "<img src=\"hero.jpg\">" }
      ],
      "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/image-alt"
    }
  ]
}

It's a linter, but for accessible HTML. Wire it up, run it, read the output, fix.

How we wired it into CI

Axe-core has three execution modes, depending on context:

  1. E2E tests with Playwright (what we use): a real browser renders the page, axe analyzes after hydration, and the test fails on critical/serious violations.
  2. Unit tests with jsdom: fast but less faithful — some issues (computed color contrast) don't surface without a real browser.
  3. In the editor via axe DevTools extension: for manual audits during dev.

Our minimal Playwright config:

import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("home page has no accessibility violations", async ({ page }) => {
  await page.goto("/");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa"])
    .analyze();

  const critical = results.violations.filter(v =>
    v.impact === "critical" || v.impact === "serious"
  );
  expect(critical).toEqual([]);
});

The two important things: filter by wcag2a and wcag2aa (the realistic levels — AAA is strict and almost nobody hits it), and block only on critical/serious. Moderate and minor violations get reported but don't break the build, because they include subjective warnings that create noise if you make them blockers.

What it does catch

Axe-core catches, undisputedly, a concrete and valuable list:

  • Images without alternate text — including <img> without alt and SVGs without aria-label
  • Inputs without an associated label — including subtle cases like placeholder used as a label
  • Insufficient color contrast — computes the ratio and compares to WCAG AA
  • Buttons without accessible text — a <button> with only an SVG icon and no aria-label
  • Skipped headings — an <h3> without a preceding <h2> in the section
  • Invalid or redundant ARIA roles
  • Pages without <html lang> — one of the most common, with critical impact for screen-reader users
  • Tables with headers missing scope

This is the boring, mechanical side of accessibility. It's where humans get bored, tired, and start missing things. It's exactly where a deterministic tool shines.

What it doesn't catch

Axe is honest about its limits — its own docs say it captures 30–50% of real accessibility issues. The other 50–70% needs human judgment:

  • Whether the alt text is good or bad. Axe sees alt exists; not whether it says "image" instead of describing the image.
  • Whether focus order is logical. The DOM hierarchy can be fine and keyboard experience still confusing.
  • Whether an animation makes someone with vestibular sensitivity ill. prefers-reduced-motion exists, but using it well goes beyond a check.
  • Whether a custom component is keyboard-navigable. Axe sees ARIA attributes; it doesn't test real behavior.
  • Whether an error message is understandable. An input having aria-invalid is good; the message reading "format invalid" is useless.

The goal isn't axe replacing judgment. It's freeing judgment to focus on the non-mechanical. If axe handles 50% for free, the other 50% stops feeling intimidating.

Why treat it as spell-check

The mental shift we made: stop treating accessibility as a quarterly project ("our a11y audit") and treat it as a continuous property of the code, the same as linting or tests. The exact metaphor: spell-check.

Nobody writes a Word document and then "runs spell-check" as a project. You have it on the whole time. When a red squiggle appears, you decide — fix, ignore, add to dictionary. But the decision lands on time, not three months after publication.

Axe in CI is that. Each PR runs the "checker." When there's a violation, you decide right then — fix it, add an aria-label, declare a justified exception in code. Accessibility stops being debt and becomes a property of the commit.

The real cost of getting started

The typical argument is "we don't have time to fix everything axe will flag." Reasonable as a fear, false as a fact.

When we added it to Agentikas, the first run found 23 violations, 8 critical. We fixed them in a day. The criticals were obvious — an <img> without alt in the hero, a language picker without aria-label, a modal without role="dialog". Things that take longer to type out than to fix.

Since then, every PR has zero critical or serious violations — because they don't merge if there are. Debt doesn't pile up.


Axe-core is open source at github.com/dequelabs/axe-core. We integrate it in Agentikas via @axe-core/playwright. The exact CI config is in github.com/agentikas/agentikas-blog — fork it.

Comments

Loading comments…