Logo

Developer Blog

Anton Lijcklama à Nijeholt

Dedicated to cultivating engaging and supportive engineering cultures

A man in a wheelchair halfway on a stairway

Accessibility with pa11y

For many of us, browsing the web is something we do without conscious thought. However, millions of people with vision impairment face significant barriers to their online experience. The Web Accessibility Statistics below give us an impression on the number of people affected:

  • 4.9% of U.S. adults have a vision disability with blindness or serious difficulty seeing even when wearing glasses, requiring screen readers
  • There are an estimated 300 million people in the world with color vision deficiency which requires color-adjusting tools on sites
  • About 16% of people who use screen readers have multiple disabilities

Another source, Nature, published research stating approximately 43.3 million people worldwide are blind and 295 million have MSVI. MSVI stands for Moderate to Severe Vision Impairment, where moderate means that, even with their best corrective lenses, a patient still has trouble seeing clearly. To put the statistics in perspective: the number of blind people worldwide roughly equals Argentina’s population (46.7 million as of 2025), while those with moderate to severe vision impairment equals Indonesia’s population (286 million as of 2025). The Dutch ophthalmologist Herman Snellen (1834-1908) developed a method for measuring visual acuity and can help us understand the impact MSVI has on people’s lives.

Classification Acuity
Blind < 3/60
Severe Vision Impairment < 6/60
Moderate Vision Impairment 6/18
Normal Vision 20/20

These fractions give a good sense of how one’s vision compares to someone with normal vision. It’s a comparison of the patient’s vision to a person with normal vision. For example, a 3/60 reading means that the patient can discern the letters on a Snellen Chart from 3 feet away that someone with normal vision could discern from 60 feet away. A “moderate” vision impairment therefore means a two-thirds reduction in vision…

Common difficulties #

What are some of the difficulties people with vision impairment experience? The table below shows WebAIM’s analysis of the top one million web sites, highlighting the most common accessibility failures. These findings impact the daily lives of over 300 million people, and many of these issues are trivial to fix.

WCAG Failure Type % of home pages
Low contrast text 79.1%
Missing alternative text for images 55.5%
Missing form input labels 48.2%
Empty links 45.4%
Empty buttons 29.6%
Missing document language 15.8%

Let’s get started #

Now being aware of the negative impact of bad accessibility, I decided to work on improving my own blog. As I don’t like half-baked solutions, I want it to become an integral part of my build system. Not being aware of any tools around web accessibility, I was pleasantly surprised to find a list of Web Accessibility Evaluation Tools. My one and only non-negotiable requirement is that this tool needs to be a CLI as it needs to be integrated into my local build pipeline. In the list I shared earlier, my eye caught a tool called pa11y:

“A command-line tool which iterates over a list of web pages and highlights accessibility issues.” — https://pa11y.org/

pa11y #

Accessibility is sometimes written as a11y (11 characters between the “a” and “y” of the word “accessibility”). In this case, pa11y is our friend, helping us identify accessibility issues. A clever wordplay from the authors of this framework by prefixing a11y with a p. Browsing their official documentation, I immediately appreciated their opening statement:

Here at Pa11y, we think making the web more accessible improves it for everyone. So we publish a range of free and open source tools to help designers and developers make their web pages more accessible: — https://pa11y.org/

From the get-go, you can see that the developers thought about usability. The CLI can be run against a sitemap.xml, so there’s no need to manually provide all the URLs of your web site. Let’s see how this works in practice and discover how many accessibility issues I need to address:

console
❯ pa11y-ci --sitemap http://localhost:1313/sitemap.xml
Running Pa11y on 22 URLs:
...
 > http://localhost:1313/2025/08/29/the-liability-of-everything/ - 8 errors
 > http://localhost:1313/2024/08/21/add-pandoc-metadata-with-git-log/ - 26 errors
 > http://localhost:1313/2024/08/10/secret-message-to-the-primeagen/ - 35 errors
 > http://localhost:1313/2024/07/02/404-netrw-not-found/ - 99 errors
 > http://localhost:1313/2024/06/06/my-portable-dev-setup-with-ansible/ - 131 errors
...

Zooming in, there appear to be quite a few contrast issues related to syntax highlighting of code:

console
 • This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 4.37:1.
   Recommendation:  change background to #313f53.

   (#markdown-content > div:nth-child(9) > div > pre > code > span:nth-child(2) > span > span:nth-child(3))

   <span class="p">{</span>

Fortunately, we can easily identify duplicate errors with awk. But first, let’s keep only the bullet items and delete all other lines in Vim/Neovim:

vim
:g!/•/d

Now we can use awk to count the duplicate lines and print them to the screen:

vim
:%!sort | uniq -c | awk '{count=$1; line=substr($0, index($0,$2)); printf "\%3d: \%s\n", count, line}' | sort -r

Finally, let’s have a look at the list of unique accessibility issues identified by pa11y:

console
836: • This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.96:1.
145: • This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.21:1.
142: • This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 4.1:1.
 91: • This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 4.41:1.
 56: • This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 2.1:1.

All these errors relate to contrast ratios in syntax highlighted code — something I honestly never perceived as problematic. And why do all the syntax highlighting color schemes I tried run into the same contrast issues? Something else must be wrong. Changing the page background to black for the dark theme doesn’t improve the contrast ratio at all, so something is definitely off. Why is pa11y reporting a different contrast compared to what I’m seeing on my screen?

After investigating, I discovered that pa11y was detecting background colors from parent elements in the DOM tree, even though they weren’t visible to users. It was computing contrast ratios on these hidden styles. By making two targeted CSS changes, I resolved these false positives:

  • Restricted the background color of the inline code elements using the :not(pre) > selector.
  • Removed the background color from the code element nested within the .highlight class.
diff
@@ -178,7 +178,7 @@ ul.pagination {
-    code {
+    :not(pre) > code {
       @apply dark:text-slate-300 bg-slate-100 dark:bg-slate-700 p-2 border-slate-300 dark:border-slate-500;
     }

@@ -195,7 +195,7 @@ ul.pagination {
    .highlight-wrapper {
      @apply mb-7 border border-slate-300 dark:border-slate-900 rounded-b-xl shadow shadow-slate-700/10 dark:shadow-slate-900/10 dark:bg-slate-950/75;

      .highlight {
        pre.chroma {
          @apply overflow-auto px-5 py-5 rounded-b-xl;
        }

        code {
-         @apply font-mono text-base shadow-none leading-8 p-0 border-0 bg-slate-100 dark:bg-slate-700;
+         @apply font-mono text-base shadow-none leading-8 p-0 border-0;
        }
      }
    }

There were still a few small tweaks I had to make to the colors. For example, did you know that bright red (#ff0000) on a white page (#ffffff) is too low (3.99 < 4.5) of a contrast for normal text? Fortunately, there’s a contrast checker tool where you can easily verify which color combinations pass the WCAG checks.

Integrating pa11y into the build process #

To automate accessibility testing, I integrated pa11y into my build process. I use a Makefile rather than npm scripts because I find it more maintainable and easier to read:

makefile
IP := $(shell ifconfig en0 | awk '/inet / {print $$2}')

clean:
	rm -rf "./public/"

install:
	npm install

build: clean install
	hugo --ignoreCache

preview: clean install
	hugo --ignoreCache serve --themesDir ./themes --bind=0.0.0.0 --baseURL=http://$(IP):1313

deploy: build
	hugo deploy --target=production

In my Makefile, I run hugo and bind it to all network interfaces (--bind=0.0.0.0), allowing me to check my responsive blog on any device using my development machine’s IP address. The --baseURL flag ensures all requests are properly redirected. Without it, clicking a link on a device would redirect to localhost, which is only accessible from the development machine. As a command-line application, pa11y returns zero on success and a non-zero exit code on error. Let’s create a pa11y rule in the Makefile to start the web server, wait for it to be ready, and then run pa11y:

makefile
pa11y:
	$(MAKE) preview & \
	export PREVIEW_PID=$$!; \
	echo "PREVIEW_ID=$$PREVIEW_PID"; \
	trap 'kill $$PREVIEW_PID' EXIT; \
	$(MAKE) wait-for-server; \
	pa11y-ci --sitemap "http://$(IP):1313/sitemap.xml";

wait-for-server:
	echo "Waiting for server..."; \
	while ! curl -sSf http://$(IP):1313 > /dev/null; do \
		echo "Server not available, waiting..."; \
		sleep 1; \
	done; \
	echo "Server is ready!";

When the pa11y rule fails, we’ll be greeted with an error code:

bash
❯ make pa11y
...
✘ 11/22 URLs passed
make: *** [pa11y] Error 2

When the pa11y rule succeeds, the program terminates with a 0 exit code.

bash
❯ make pa11y
...
✔ 22/22 URLs passed

Loading configuration file #

pa11y supports loading a configuration file with the argument --config:

bash
pa11y-ci --config pa11y.json ...

A few options caught my interest, namely concurrency and viewport:

Argument Description
concurrency The number of tests that should be run in parallel. Defaults to 1.
useIncognitoBrowserContext Run test with an isolated incognito browser context; stops cookies being shared and modified between tests. Defaults to true.

Running tests in parallel speeds up the process significantly, and the viewport can be used to specify the exact dimensions of the web page to be tested. Here’s the configuration I ended up with:

json
{
  "defaults": {
    "concurrency": 16,
    "useIncognitoBrowserContext": false,
    "timeout": 10000,
    "viewport": {
      "width": 1920,
      "height": 1024
    }
  }
}

Important: I had to set useIncognitoBrowserContext to false, otherwise I’d encounter multiple errors. It appears to be something related to Puppeteer, which is the underlying library used by pa11y. Due to time constraints, I abandoned investigating the root cause. Since I’m not using cookies, the isolation useIncognitoBrowserContext provides isn’t necessary. The log below is what you will be greeted with when you do run the configuration with concurrency > 1 and useIncognitoBrowserContext set to true:

console
Running Pa11y on 22 URLs:
 > http://0.0.0.0:1313/about/ - 0 errors
 > http://0.0.0.0:1313/ - 0 errors
 > http://0.0.0.0:1313/posts/ - 0 errors
 > http://0.0.0.0:1313/2023/04/22/tech-debt-is-like-a-credit-card-easy-to-use-but-the-interest-rate-is-high/ - Failed to run
 > http://0.0.0.0:1313/2025/09/03/when-white-is-too-bright/ - 0 errors
 > http://0.0.0.0:1313/2025/08/29/the-liability-of-everything/ - 0 errors
 > http://0.0.0.0:1313/2025/09/07/accessibility-with-pa11y/ - 0 errors
 > http://0.0.0.0:1313/2021/12/01/compile-safe-builder-pattern-using-phantom-types/ - Failed to run
 > http://0.0.0.0:1313/archive/ - Failed to run
...

Errors in http://0.0.0.0:1313/2023/04/22/tech-debt-is-like-a-credit-card-easy-to-use-but-the-interest-rate-is-high/:

 • Error: Protocol error (Target.closeTarget): No target with given id found

Errors in http://0.0.0.0:1313/2021/12/01/compile-safe-builder-pattern-using-phantom-types/:

 • Error: Protocol error (Target.closeTarget): No target with given id found

Errors in http://0.0.0.0:1313/archive/:

 • Error: Protocol error (Target.closeTarget): No target with given id found

Makefile for multiple profiles #

I was a bit disappointed to discover that a configuration file is bound to a single viewport. I had hoped to define multiple profiles with different viewport settings in a single file, but that doesn’t appear to be possible as of this writing. The good news, however, is that you can easily add this behaviour in a simple Makefile.

makefile
pa11y-all:
	$(MAKE) preview & \
	export PREVIEW_PID=$$!; \
	echo "PREVIEW_ID=$$PREVIEW_PID"; \
	trap 'kill $$PREVIEW_PID' EXIT; \
	$(MAKE) wait-for-server; \
	echo "Running all pa11y tests..."; \
	$(MAKE) pa11y-desktop-1280 && \
	$(MAKE) pa11y-desktop-1440 && \
	$(MAKE) pa11y-desktop-1920 && \
	$(MAKE) pa11y-desktop-2560 && \
	$(MAKE) pa11y-desktop-3840 && \
	$(MAKE) pa11y-mobile-portrait && \
	$(MAKE) pa11y-mobile-landscape; \
	echo "All pa11y tests completed successfully";

pa11y-%:
	@echo "Running pa11y with profile: $*"
	pa11y-ci --config pa11y-$*.json --sitemap "http://$(IP):1313/sitemap.xml";

With the philosophy of convention over configuration, we can now create the corresponding JSON files (e.g. pa11y-desktop-1280.json, pa11y-desktop-1440.json, etc.) and have them all tested one by one. Running all 7 profiles takes some time, though. From start to finish it took around 45 seconds, of which 10 seconds were needed to start the server. In retrospect, it’s not something I’d run before every build, but it works well as a pre-deployment check.

Conclusion #

Accessibility is not an add-on — it requires time and genuine effort. It’s been an interesting journey learning about accessibility and pa11y: a command-line tool highlighting accessibility issues. Please help make the internet more accessible for the millions of people who depend on it by prioritizing accessibility in every one of your projects.

Posted on Dec 9, 2025