← Back to Blog

Your accessibility toggles combine, and nobody tests the combinations

· 9 min read Your accessibility toggles combine, and nobody tests the combinations

A site I look after has two switches in its accessibility panel. One turns on dark mode. One turns on high contrast. Both of them work. Both of them had been tested. The site passed its contrast audit.

Turn both on at once and most of the page disappears.

Not "gets harder to read". Black text on a dark navy card measures 1.01 to 1. That is the same colour twice. I measured 7,912 elements in that state across the fleet, and every one of them was text a person had asked to see more clearly, rendered invisible by the feature they used to ask.

Nobody had looked, because two switches read like two things to check. They are four.

Why the fourth state is always the broken one

The high contrast palette was written the way most of them are, for white paper. It sets the page background to white, the body text to black, the borders to solid black. Sensible.

It also never touched the three colour variables that dark mode uses for its surfaces. So with both switches on, the cards, the table headers and the side panels all kept painting themselves dark, while every piece of text on top of them turned black.

Neither feature is wrong on its own. The bug lives in the gap between them, and the gap is exactly where nobody is looking, because a contrast audit runs the page in its default state. Whatever tool you point at the site loads it cold, measures once, and reports. The toggles sit behind a button in the corner that most visitors never press, and the people who do press it are the people who need it.

If your site has any user-facing display preference, count the combinations before you count the checks. Two switches is four states. Three is eight.

The second bug was one line, and it had been there for months

Same fleet, different complaint from the owner: "the spacing looks bad, everything is jammed together."

The stylesheet opens with a reset that zeroes every margin on every element. That is normal. The next rule sets the font, weight, line height and colour for h1 through h6, and sets no margin at all. The rule after that gives paragraphs their bottom margin back.

Headings never got theirs back. So every heading in flowing content sat flush against the block underneath it. A section title touching the top edge of the table it introduces. A subheading with its baseline on the first row of a list. Measured across 350 pages, 2,090 headings had zero pixels of air below them.

This is the kind of thing that looks like a design opinion until you measure it. Nobody files a bug that says "the margin collapsed", they say the page looks cheap. The fix is one rule, and the reason it survived so long is that it never rendered as broken, only as slightly wrong everywhere.

4.5 to 1 is a floor, and a floor is what washed out looks like

The same fleet had already been through a contrast pass. Somebody had derived every dark mode heading colour by walking the brand colour toward white until it hit 4.5 to 1, then stopping.

That is the WCAG AA minimum for normal text. It passes. It also looks like grey mush, because a colour sitting exactly on the threshold has no margin for the actual conditions people read in: a phone at 40 percent brightness, a laptop tilted back, a bright room.

Two changes fixed the way it read:

Derive to 7 to 1 for headings and body ink, not 4.5. That is the AA minimum for large text turned into a target for everything. Links can sit lower, around 5.5, because they need to stay distinguishable in hue from the ink around them, not just legible.

Lighten in HSL, not toward white. Mixing a colour with white raises its luminance and destroys its saturation at the same rate. One brand navy lifted to 7 to 1 by mixing landed on a flat grey with no hue left in it. The same navy lifted by raising HSL lightness, holding hue and saturation, hit the same ratio and still read as navy. Same number, completely different page.

Then the measuring tool started lying

This is the part I did not expect, and the part I would want somebody to tell me before I spent a day on it.

I built a script that loads every page in every display state and measures the contrast of every piece of text against the background actually painted behind it. It found real bugs. It also, three separate times, reported failures that did not exist, and each time I nearly "fixed" a site that was fine.

A registered service worker answers your test's requests. Once the fleet got offline support, the first page my script loaded registered the worker, and every page after that was served from the worker's cache instead of the files I was testing. Roughly 200 phantom failures per site. One site read 210 failures; with service worker registration stubbed out before any script ran, the same site read 8. If you automate a browser against a site that has a service worker, disable it, or you are measuring a snapshot from an unknown point in the past.

requestAnimationFrame does not run in a background tab. My script opened one browser page per display state, which means only one of them is ever in the foreground. Browsers throttle animation frames in background tabs to save power, so anything waiting on a frame in the other three just sat there. A ninety second run became twenty five minutes of timeouts. Bring the page to the front before measuring, and poll on a timer rather than a frame.

Toggling a class and reading the result gives you the old value. Flipping the theme attribute and then asking for the computed style returned the previous state's colours, even after forcing a reflow. Custom properties seem to be the trigger. The reliable way is to put the preference in local storage and load the page, which is also exactly how a returning visitor arrives, so you are testing the real path instead of a synthetic one.

There is a fourth one worth naming. A page that never received its stylesheet looks identical, to a measuring script, to a page whose every colour is broken: white text on a white canvas, everywhere. If your harness can load a page without CSS, it must treat that as an error, not as data. Mine did not, and it produced a full page of failures for a site that was rendering perfectly.

The general rule I took away: when a measurement says something surprising, check the instrument before you change the thing being measured. Twice I got as far as writing a fix.

Two more that cost real time

Your local copy may be behind production. Before deploying, I diffed every live page against my working copy. Another session, four hours earlier, had rewritten every internal link on the live sites to a cleaner form and never saved that back to the working tree. Deploying would have silently reverted 2,584 links. The version markers matched, the stylesheets matched, the structured data matched. Only a page by page comparison found it. If more than one thing can deploy your site, compare against live before you push, not after.

A gate that cries wolf gets ignored. My deploy script checks that the security headers survive the deploy, because a deploy from the wrong working directory once stripped every one of them while reporting success. Good check. On its first real run it announced that a site had lost its entire content security policy. It had not. A single network request had failed, and a failed request reads as "no headers present". Every check in that script now retries three times before it believes a bad answer. A check that is wrong occasionally is worse than no check, because you learn to click past it.

What I would do differently from the start

Write down how many display states your site actually has, then measure all of them. Not the two you designed, the product of every switch.

Set the contrast target above the minimum. The minimum is where "technically passing" and "looks broken" overlap.

Make the measuring harness fail loudly when it cannot measure. Silent degradation in a test tool produces confident nonsense.

Before deploying anything to more than one site, diff live against local.

And check the combination that nobody uses, because the person who uses it is the person the feature was for.

If you are starting from nothing rather than fixing something, the ground level version of all of this is in The $97 Launch, which covers getting a small business site to WCAG conformance without paying an agency for the privilege.

Two files you can run against your own site

Everything above is written up as two checklists you can download and work through. Both are plain Markdown, free, no signup.

File What is in it
display-state-audit-checklist.md The state matrix worksheet, the combined-state token fix, a contrast pass you paste straight into the DevTools console, a heading-spacing pass, the derivation targets, and a sign-off table with one row per state
audit-harness-traps.md For anyone automating this past about ten pages: the seven ways an automated browser lies to you, with the three-line fix for each, and a self-check that proves your harness can still fail

The contrast snippet in the first file is the fastest way to see whether you have this problem. Set your site to whichever state you least expect anyone to use, paste it into the console, and read the number it prints.

Fact-check notes and sources

WCAG 2.2 contrast minimums: Success Criterion 1.4.3 Contrast (Minimum) requires 4.5 to 1 for normal text and 3 to 1 for large text. Source: W3C, Understanding SC 1.4.3.

The 7 to 1 figure: Success Criterion 1.4.6 Contrast (Enhanced) is the AAA level and requires 7 to 1 for normal text, 4.5 to 1 for large. Source: W3C, Understanding SC 1.4.6.

Large text definition: at least 18 point, or 14 point bold. Source: W3C, WCAG 2.2 glossary.

Service workers intercept requests from the page they control, including requests an automated browser makes, which is why one has to be disabled before measuring. Source: MDN, Using Service Workers.

Animation frame callbacks are throttled or suspended in background tabs. Source: MDN, Window.requestAnimationFrame.

Element opacity composites after colour is resolved, which is why a rule that sets 90 percent opacity on a paragraph changes its effective contrast without changing any colour value a stylesheet can see. Source: MDN, opacity.

The failure counts in this post are my own measurements across 350 pages of one small site fleet, taken before and after the work, in all four display states. The sites are not named because the lesson generalises and the client does not need the traffic.

Related reading

If your text contrast fails WCAG, one in twelve male visitors sees the problem first: the free checker, and why contrast is the failure people actually report.

The accessibility and UX audit tools I built after Lighthouse stopped being enough: what a general purpose audit misses on a real site.

Most forms fail accessibility before anyone ever fills them out: the same "passes the audit, fails the person" shape, in forms.

Audit your own web app security: the same method applied to a different surface, measure first, then change one thing at a time.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026