Most teams treat accessibility as an audit. Someone runs a scanner near the end of a project, produces a spreadsheet of 200 issues, and the team fixes the cheap ones before launch. Six months and forty commits later the site has quietly regressed, and nobody finds out until a customer complains or a letter arrives.

We do it differently on this site: axe-core runs on every pull request, crawls every URL in the sitemap, and fails the build on any violation. Not a warning. Not a report. A red X that blocks the merge.

That single change — from audit to constraint — is worth more than any individual fix, and it is worth explaining why, along with the things it emphatically does not solve.

Why a build gate and not a report

Reports get triaged. Build failures get fixed.

That is the entire argument, and it is the same reason type errors and failing tests block merges while linter warnings accumulate in the thousands. A signal that can be ignored will be ignored, not because engineers are lazy but because there is always something more urgent than a warning that nothing depends on.

The economics matter too. A contrast failure caught in CI costs about ninety seconds: change a token, push. The same failure caught in an audit eight months later costs a triage meeting, a ticket, a designer’s opinion, a regression test, and a release. Same defect, two orders of magnitude difference in cost, and the only variable is when you found it.

Building the gate, and what got in the way

The obvious tool is pa11y-ci, and we started there. It did not survive contact with a modern stack for two specific reasons worth knowing before you spend an afternoon on it.

First, it bundles axe 4.2, from 2021. Tailwind CSS v4 emits modern colour syntax — oklab(), color() — and a 2021 axe cannot parse it. Every colour on the site became unanalysable, which is worse than useless: it fails loudly on things that are fine.

Second, pa11y promotes axe’s incomplete results to hard errors. “Incomplete” means axe could not determine the answer, which fires constantly for benign cases like text sitting over a decorative background-image. You end up with a build that fails for reasons nobody can act on, and a team that learns to bypass the gate.

What we run instead is about eighty lines: Puppeteer plus @axe-core/puppeteer against a current axe, walking the sitemap and reporting violations only. The whole thing is one script and one workflow file. Accessibility tooling has a reputation for being heavyweight, and for the automated slice of it that reputation is undeserved.

What automation actually catches

Be clear-eyed about this: automated testing catches somewhere around a third of WCAG issues. That third is worth having, because it is precisely the mechanical, high-frequency, easy-to-regress category:

  • Colour contrast below 4.5:1
  • Form inputs with no associated label
  • Images with no alt attribute
  • Buttons and links with no accessible name — the icon-only button is the classic
  • Heading levels that skip, and pages with no h1
  • Missing document language, duplicate IDs, broken ARIA references
  • Landmark structure problems

Every one of those is a bug a human reviewer will eventually miss, because catching them requires the kind of tireless mechanical scanning humans are bad at and machines are good at.

What it will never catch

The other two-thirds is where the actual user experience lives, and no scanner will help you.

Whether alt text is useful. alt="image" passes. alt="chart" on a chart carrying your entire argument passes. The rule is mechanical; the judgement is not.

Keyboard traps in custom widgets. A custom dropdown that opens on click, has correct ARIA, and passes every automated check — but once you tab into it you cannot tab out. axe sees valid markup. A keyboard user sees a dead end.

Focus order that is valid but nonsensical. DOM order determines tab order; CSS grid and flexbox can reorder things visually. The result passes every check and makes no sense to anyone tabbing through it.

Whether focus returns. Open a modal, close it, and focus should return to the trigger. When it doesn’t, a keyboard user is dumped back at the top of the document with no idea where they are. Nothing automated flags this.

What a screen reader actually announces. The only way to know is to turn one on. VoiceOver on macOS is two keystrokes away and most engineers have never opened it.

The honest framing: the automated gate is a regression net, not a substitute for knowing what you are doing. It stops you shipping the mechanical mistakes so your review attention goes to the things that need judgement.

Three lessons that cost us time

aria-hidden does not exempt anything from contrast. A visible → in a CTA is real text on the screen regardless of what you told the accessibility tree. Hiding it from screen readers does not hide it from sighted users with low vision. If it is visible, it needs contrast.

Scanners cannot read gradients. A gradient background is a background-image, and axe cannot compute a contrast ratio against an image, so it either skips the check or flags it as incomplete. Any element with a gradient background that carries text needs a solid background-color fallback underneath — which you want anyway, for the moment the gradient fails to paint.

Translucent backgrounds must be checked composited. bg-white/80 over an unknown background is not white. The contrast that matters is against the composited result, which varies with whatever scrolls underneath. We replaced those with a solid token specifically so both the browser and the scanner could reason about them.

The fix that scales: tokens, not vigilance

The most valuable thing we did was not the CI gate. It was tuning the colour tokens once — primary, secondary, and tertiary text against every background, in both light and dark themes — so that any allowed combination clears AA by construction.

That converts an ongoing per-component judgement call into a solved problem. Nobody on the team has to reason about contrast when building a card, because there is no way to combine the tokens that produces a failure. The gate exists to catch the day someone reaches outside the system.

This is the general shape of accessibility work that lasts. Fixing a contrast bug fixes one bug. Fixing the palette so contrast bugs cannot be expressed fixes the category. It is the same instinct that makes a good design system valuable in the first place — encode the decision once, so it stops being a decision.

Why this is not only an ethics argument

For smaller businesses in particular, three practical things follow.

It is a legal exposure. ADA-related web claims are a real and growing category, and “we didn’t know” has never been a defence. WCAG 2.1 AA is the standard courts and settlements keep converging on, and it is achievable — this site clears it on every commit without heroics.

It overlaps almost entirely with technical SEO. Semantic headings, descriptive link text, alt attributes, proper landmarks, and a document that makes sense without JavaScript are accessibility requirements and search visibility requirements simultaneously. You are doing the work once and getting paid twice.

It is a proxy for build quality. Sites that fail badly on accessibility usually fail because the markup is div soup with click handlers. That same markup is why the site is slow, why the CSS is unmaintainable, and why every change breaks two other things — the same structural neglect that shows up as a heavy JavaScript bundle on a page that should be static.

Where to start if you have nothing

In order, because the order matters:

  1. Put the gate in first, even if it fails. Get the number visible.
  2. Fix contrast at the token level, not per-component.
  3. Tab through your primary conversion flow. Just that one. You will find something.
  4. Turn on a screen reader and try to complete a purchase or submit a form. Twenty minutes, permanently changes how you build.

Steps 1 and 2 are an afternoon and eliminate most of what a scanner will ever find on your site. Steps 3 and 4 find the things that actually stop someone using it.

If you are building something new, this belongs in the foundation rather than in a remediation project later — it is part of how we do full-stack web and mobile work rather than a line item on top of it. If you have an existing site and no idea where you stand, that is a short, cheap engagement to find out: tell us what you’ve got and we will give you the honest number before anyone proposes a project.