Why Automated Contrast Checkers Pass Palettes That Still Fail Users
A storefront passed its contrast audit with zero failures, and a customer with low vision could not read the checkout button. The button text sat on a gradient, the checker measured the two hex codes in the design token, and the ratio came back 5.2 to 1. The rendered button measured under 3 to 1 where the text actually sat.
What the checker measures, and what it cannot
An automated contrast checker does one thing well: it takes a foreground color and a background color and computes a ratio. WCAG 1.4.3 wants 4.5 to 1 for normal text and 3 to 1 for large text, and the math is exact. What the checker cannot do is know what the user actually sees. Text on a photograph, text over a gradient, text on a translucent panel over a product image, text that inherits its background from three layers up: the checker sees the token, not the render. The ratio it reports is the ratio of two hex codes that may never appear together on screen.
This is not a flaw in the tools. Axe, WAVE, and Lighthouse all measure the computed colors of the element as rendered, which is more than the tokens give you, and it still misses the hard cases. A gradient that runs from light to dark behind a line of text passes at the light end and fails at the dark end, and the checker reports one number for the whole element. A background image that loads late changes the contrast after the scan ran. A semi-transparent overlay over a product photo produces a different ratio for every product. The checker is honest about the element. The element is not the whole story.
The states nobody tests
Most contrast testing happens in one state: the default. The button as it loads, the link as it sits. But a link has a hover state, a focus state, a visited state, and an error state, and each one can carry a different color. A button that passes at 4.6 to 1 in its default state and drops to 2.8 to 1 on hover fails the criterion, because the criterion applies to the text as the user encounters it, not as it loads. Placeholder text is the quietest failure here. It is not exempt from contrast rules, it is usually set to a light gray by the theme, and it is the first thing a user reads in a form. Disabled controls are exempt, and that exemption gets quoted as if it covered placeholders. It does not.
The same gap shows up in the theme updates that regress contrast without anyone noticing: a token change moves a color half a shade, the default state still passes, and a hover state somewhere in checkout falls below the line. The regression is real and the scanner is silent, because the scanner only looked at what it was pointed at.
Dark mode is a second palette, not a theme
A dark mode toggle does not recolor a page. It swaps in a second palette, with its own foreground and background pairs, its own hover states, and its own failure modes. Teams test the light palette carefully and ship the dark palette on faith, and the faith is misplaced: a gray that passes on white fails on charcoal, a muted accent that reads fine on light backgrounds disappears on dark ones, and the focus indicator, which needs 3 to 1 against its adjacent colors under WCAG 1.4.11, is often the first thing to go. Every token needs its own ratio in each mode. There is no shortcut where one mode inherits the other mode's passing grade.
The fix is a screenshot, not a second tool
Stop testing tokens and start testing renders. Take a screenshot of the page in every state that matters, default, hover, focus, error, dark mode, and sample the actual pixels behind the text. That is what the user sees, and it is what the criterion measures. A scanner comparison will tell you which tool catches which class of failure, and none of them will tell you what the gradient does at the dark end. The screenshot will. Run it in the pipeline on the states that change most often, and keep the default-state check as the floor, not the ceiling.
Frequently asked questions
What contrast ratio does WCAG actually require?
The standard is 4.5 to 1 for normal text and 3 to 1 for large text, under WCAG 1.4.3. Large means 18 point or 14 point bold and above. Non-text elements like icons and focus indicators need 3 to 1 under WCAG 1.4.11.
Does a gradient background break contrast checking?
Yes, if the gradient passes behind the text. The checker cannot know which part of the gradient sits behind which letter, so a gradient that passes at its lightest point can fail where the text actually sits. Test the rendered text over the real background, not the token values.
Are disabled buttons exempt from contrast rules?
Disabled controls are exempt from the contrast requirement, because the user is not expected to act on them. Placeholder text is not exempt, and neither are error messages or helper text. If it carries information the user needs, it has to meet the ratio.
How do I catch contrast failures in states I never test?
It tests every state the user can reach: default, hover, focus, error, and the theme variants. A button that passes in its default state and fails on hover fails the criterion. Most teams only ever test the default state, which is why the failures ship.