What It Actually Takes to Make an Accessible Website

The day we discovered our website was lying to us

On 7 April 2026 we merged a continuous integration fix to ImpactMojo, a correction to how our accessibility workflows read their own results. We expected nothing visible to happen. Instead, the fix uncovered 393 accessibility violations on the 13 pages our pa11y-ci scanner tests, hidden in plain sight since the audits were set up.

Every pull request before that fix, including the nine merged in the session before it, had been silently passing accessibility checks. Every dashboard said "all green." Every commit said "tests passing." But the underlying tools (axe-core and pa11y-ci, the two industry-standard accessibility scanners) were producing failure reports that the workflow was misreading as successes. The bug was an exit-code check: $? after a piped command captures the exit code of the last command in the pipe (in our case, tee, which always returns zero), not the upstream command we cared about. The fix was ${PIPESTATUS[0]}.

A change of a few characters exposed hundreds of hidden errors. This is the story of what we found, what we fixed, and what we learned about the gap between "technically accessible" and "actually accessible."

What WCAG 2.1 AA actually means

The Web Content Accessibility Guidelines (WCAG) 2.1 Level AA are the W3C standard for web accessibility. Level AA is the target we hold ourselves to. It is the floor: the minimum that lets people with low vision, dyslexia, motor impairments, screen readers, and many other conditions actually use a website. It is also what most government partnerships, UN agencies, and educational institutions require their digital partners to meet. Our own accessibility statement commits to it.

The rules are concrete. Text must have a contrast ratio of at least 4.5:1 against its background, or 3:1 for large text (Success Criterion 1.4.3). Every form field needs a label. Every interactive element needs a name that screen readers can announce. Colour must not be the only thing that sets a link apart from the text around it (1.4.1). Heading levels should not skip, and a link to an #anchor should land on an element that exists. Scanners flag both, although they are best practice more than strict pass-or-fail criteria.

None of this is glamorous. The World Health Organization estimates that 1.3 billion people, 16 percent of the world's population, live with a significant disability. Not every one of them meets barriers on the web, and for those who do, these rules decide whether a website includes them.

From 393 errors to zero, pass by pass

After we fixed the workflow bug and could finally see the truth, we started working through the errors. Here is what the trajectory looked like, pass by pass, in one working session:

  • Pass 1 (393 → 77 errors): One colour change on the dataverse page's "Visit" button, plus colour-token bumps on three other pages. The button was sky-500 with white text, a contrast ratio of 2.77:1, well below the 4.5:1 floor. There were roughly 260 cards on the page, so that single change cleared 271 errors in one shot.
  • Pass 2 (77 → 21): Form fields without labels, buttons and JavaScript-injected modal search inputs without accessible names, and a handful of contrast fixes.
  • Pass 3 (21 → 14): The theme switcher's default was inverted. In a headless browser such as the one our test runner uses, no colour scheme is set, so the script's test for the light scheme failed, and the page fell back to dark mode, so every audit ran against dark-theme tokens. We inverted the check on forty-one pages. That left gradient buttons, which pa11y reads as background-color: transparent and scores as white text on white. In pass 4 we added an explicit background-color fallback to twenty-seven gradient rules across three pages.
  • Pass 4 (14 → 1): Two CSS faults. In four files a selector list grouped [data-theme="light"] with body.dark-mode, so the light theme received dark-theme token values. Separating them removed most of what was left, and the gradient fallbacks had to be declared after the gradient shorthand to take effect.
  • Pass 5 (1 → 0): A single text colour on a contact button. Done.

Five passes, roughly four hours of focused work, took the count from 393 errors to zero on all thirteen pages our scanner tests.

Bar chart of scanner errors falling from 393 at the start to 77 after pass 1, 21 after pass 2, 14 after pass 3, 1 after pass 4 and 0 after pass 5
From 393 errors to zero in five passes: four hours, two people. Pass 1, a single button colour on the dataverse page, accounted for the largest single reduction.

The five lessons that travel

1. Your CI is probably lying to you about something

The deepest bug was in the checking itself: for weeks we had been adding features and shipping pull requests while believing our accessibility tests were passing. The workflow was structured in a way that was easy to miss in code review (a piped command, an exit code check, a wrapper script that posted "All pages passed" if it took the wrong branch). Telling a team to be more careful would not have caught this. Assuming that a green check might be lying, and testing the failure path, would. The workflow now reads the audit's own exit code, and a separate step fails the job when that code is non-zero. We now have a test that intentionally introduces a violation and confirms CI catches it.

2. Most accessibility bugs are caused by a small number of root causes

When you see "393 violations," it sounds like a project. But 271 of those came from one colour on one button class, repeated across roughly 260 cards on a single page. Most of the rest traced to a handful of shared causes: brand colours used as text, unlabelled form fields, an inverted theme default and a selector bug. The work was to find those five patterns, and the 393 followed from them.

Automated scans earn their place by clustering errors so that the patterns show, even though they miss plenty (see below).

Six boxes naming the six error types that make up 96 percent of detected errors in WebAIM Million 2026: low contrast text, missing image alt text, missing form labels, empty links, empty buttons and missing document language
In WebAIM's 2026 survey of one million home pages, six kinds of error made up 96 percent of everything detected: low contrast text, missing image alternative text, missing form labels, empty links, empty buttons and a missing document language. Fixing them in the template clears most of a scan before you look at a per-page count.

3. Brand colours and accessibility are in tension, and it takes design work to resolve it

Our brand uses sky-500 (#0EA5E9) prominently. White text on a sky-500 button gives 2.77:1, and so does sky-500 text on a white background, because the ratio is the same in both directions. Both fail WCAG AA. So do indigo-500 (4.47:1 as text, just under the line), emerald-500 (2.54:1) and amber-500 (2.15:1). These are the default bright choices in almost every modern design system, including Tailwind, Material, and Ant Design.

Keep the brand and define two versions of every brand colour: a "decorative" version (used for backgrounds, borders, focus rings, icons) and a "text" version that is two or three steps darker on the same hue. Sky-500 stays for the gradient backgrounds; sky-800 (#075985) takes over for any sky-coloured text. Indigo-500 stays for fills and borders; indigo-700 (#4338CA) is the indigo text. And so on.

This is design work, and it belongs in your design system before the code is written. Retrofitting it across an existing site is exactly the kind of slow, mechanical work we just spent four hours on.

Brand colours vs WCAG AA contrast: fails and fixes Comparison table showing four common brand colours on a white background. Left column shows failing ratios: sky-500 at 2.77:1, indigo-500 at 4.47:1, emerald-500 at 2.54:1, amber-500 at 2.15:1. Right column shows passing variants: sky-800 at 7.56:1, indigo-700 at 7.90:1, emerald-800 at 7.68:1, amber-800 at 7.09:1. All right-column colors meet or exceed the 4.5:1 WCAG AA minimum. Brand colours vs WCAG AA (4.5:1 floor) Tailwind-500 text on white fails. The -700/-800 variants pass comfortably. Fails AA ✗ Passes AA ✓ sky-500 #0EA5E9 Sky 500 text 2.77 : 1 sky-800 #075985 Sky 800 text 7.56 : 1 indigo-500 #6366F1 Indigo 500 text 4.47 : 1 indigo-700 #4338CA Indigo 700 text 7.90 : 1 emerald-500 #10B981 Emerald 500 2.54 : 1 emerald-800 #065F46 Emerald 800 7.68 : 1 amber-500 #F59E0B Amber 500 text 2.15 : 1 amber-800 #92400E Amber 800 text 7.09 : 1 Using the -500 shade as text is where the trouble starts. Define a "text" variant two or three steps darker on the same hue.
Every major design system has this trap. The fix is to define decorative and text variants of each brand colour up front, in the design system.

4. Automated tests miss things, but they miss less than you think

Teams sometimes defend skipping automated tests by saying "automated tests can't catch everything, so we rely on human review." The first half of that sentence is true. It makes a case for adding human review alongside the scans.

Automated tools like axe-core and pa11y find a large share of accessibility issues and miss the rest. In the UK Government Digital Service's 2017 test, single tools found between 17 and 41 percent of the 143 barriers planted on a deliberately inaccessible page, and 29 percent were missed by every tool. Deque's 2021 analysis of more than 13,000 pages found that its axe tools covered 57 percent of issues by volume. They are excellent at colour contrast, missing labels, broken anchor targets, ARIA misuse, heading hierarchy, and structural issues. They are bad at the things humans are good at: whether the page makes sense when read top-to-bottom by a screen reader, whether the keyboard focus order matches the visual order, whether interactive widgets are operable without a mouse, whether form errors are announced when they appear.

You need both. Automated tests catch the high-volume mechanical issues so that human review can focus on the things that require judgement. A team that skips them has the same bugs and finds fewer of them.

5. Most users will never notice the bugs you are fixing

Here is the plain version. Most ImpactMojo users have normal vision, modern devices, and good lighting. They will see no visual difference between sky-500 text and sky-800 text, and they will never know we changed anything. Does that mean the work was unnecessary?

No. The work is for the people who do notice. Older users whose contrast sensitivity has declined. Users with low vision or colour blindness. Users on cracked phone screens or in bright sunlight. Users with screen readers who cannot use a form field that has no label. Users with motor impairments who cannot click a button that has no accessible name. These users were always there. They were just unable to use the parts of our site we never tested.

We work in development education, in a region where many of our users are older, on shared devices, on slower connections, with regional-language screen readers. The accessibility floor is not optional for us. It is what allows the site to reach the audience we say we serve. Our accessibility statement says a platform that cannot be used by people with disabilities is not actually free.

What to do if you are starting from scratch

If you are about to build a website, or you are auditing one for the first time, here is the shortest path we know:

  1. Pick a contrast checker before you pick a colour palette. The WebAIM Contrast Checker is free and definitive, and applies the WCAG contrast formula. Test every text-on-background combination in your design system before you ship. If you cannot get to 4.5:1 with a brand colour as text, define a darker variant for text use.
  2. Run axe-core or pa11y on at least your top five pages, on every pull request. Make the test fail the build if a serious violation is introduced. This is one afternoon of CI setup that pays for itself within weeks.
  3. Verify your CI actually fails when it should. Push a test commit with a deliberately broken contrast ratio. If your build still says green, your test pipeline is lying to you. Fix that first.
  4. Label every form field. Use a real <label for="..."> element, or an aria-label attribute, or both. A placeholder is not a label. Your screen-reader users will thank you, and your search-engine ranking will improve.
  5. Make every anchor link point to something that exists. If your jump nav points to #data-technology, make sure there is an element with id="data-technology" on the page. If the target is rendered by JavaScript, add a static placeholder so it exists at load time.
  6. Test in a screen reader at least once. Mac users can turn on VoiceOver with Cmd+F5. It will be uncomfortable. That is the point: the people who depend on a screen reader cannot switch it off.
Six-step accessibility starter checklist A numbered checklist of six actions for teams beginning accessibility work: pick a contrast checker, run axe-core or pa11y on every pull request, verify CI actually fails when it should, label every form field, make every anchor link point to something that exists, and test in a screen reader at least once. Starting accessibility from scratch Six concrete actions that pay for themselves within weeks 1 Pick a contrast checker before you pick a colour palette Use WebAIM's free contrast checker. Test every text-on-background combo in your design system BEFORE you ship. 2 Run axe-core or pa11y on your top 5 pages, every PR Make the build fail on serious violations. One afternoon of setup pays for itself within weeks. 3 Verify your CI actually fails when it should Push a test commit with a deliberately broken contrast. If the build stays green, fix your test pipeline first. This is how we hid 393 violations. 4 Label every form field Real <label for="..."> or aria-label, not placeholder text. Screen readers + SEO both benefit. 5 Make every anchor link point to something that exists If JS renders the target, add a static placeholder so it exists at load time. 6 Test in a screen reader at least once Mac: Cmd+F5 for VoiceOver. It will be uncomfortable. That's the point: your users can't turn it off.
Step 3 is the one most teams skip, and it is the one that would have shown us the 393 hidden errors sooner.

The hardest part of accessible web development

The tools are free, the rules are published and the tension between brand and contrast can be settled in a design system. The hardest part is the discipline of treating accessibility as a continuous concern after the first audit is done.

Every new feature can introduce a new violation. Every JavaScript widget that injects content into the page is a potential source of unlabelled inputs, missing names, and unreachable anchors. Every brand colour update can knock a critical text element below the contrast threshold. Without an automated gate that catches these regressions immediately, even the best-intentioned team will gradually drift back into inaccessibility.

What we fixed in April included the gate itself, the system that now blocks a pull request carrying a serious violation on the pages it audits. The 393 errors were the visible payment for a year of accumulated tech debt. The real work is keeping the gate honest from now on.

"Accessibility is the floor that lets your features reach everyone."

What we shipped, in numbers

  • 2 PRs (#359) fixed two workflow bugs that were silently hiding accessibility failures: the exit code lost behind tee in the axe and pa11y workflows, and a JSON parser that broke when pa11y printed extra lines after its results.
  • 1 PR (#362 and #365) fixed the inverted theme script default that was forcing headless tests into dark mode, on 41 pages and 27 blog posts.
  • 4 files with a CSS selector grouping bug where [data-theme="light"] was incorrectly grouped with body.dark-mode, causing the "light" theme to apply dark token values.
  • 27 gradient rules across three pages got explicit background-color fallbacks so pa11y could compute their contrast correctly.
  • 14 form fields across multiple pages got proper aria-label attributes (including five JavaScript-injected modal search inputs that we fixed in one place in js/learning-tracks.js).
  • 23 brand-colour text uses got bumped from sky-500, indigo-500, emerald-500, amber-500, etc. to their darker AA-compliant variants, through CSS overrides in 24 files.
  • 1 axe-core configuration update to exclude third-party widgets (Intro.js tour, UserWay, Google Translate) that we do not control.
  • 13 of 13 tested pages passed pa11y-ci when the work finished. 5 of 5 tested pages passed axe-core with zero serious violations. Both lists have since grown: the repository now audits 16 pages with axe-core and 23 with pa11y-ci. The CI gate is honest.

If you take one thing away from this post

It is this: accessibility is a property of your development process. If your process catches violations the moment they are introduced, you stay accessible. If it does not, you drift. There is no in-between, and there is no "we'll get to it later" that actually happens.

Set up the gate. Verify the gate works. Then trust the gate. The roughly one in six of your users who need it will never write you a bug report: they will just stop visiting. The work to keep them is small if you do it continuously, and impossible if you do not.

If you are a development organisation building digital tools for the people you serve, this is the bare minimum, because the people you say you serve deserve to be able to use what you build.

Accessibility in development work goes beyond websites. Our free Disability Inclusion 101 deck covers the wider practice of building programmes that disabled people can actually use.