Bhinish Dhiman
Put contrast checking in CI so it fails the build
Write your colour tokens into a test that asserts every text-on-surface pair against its contrast requirement, and run it in CI. A token change that breaks contrast then fails the build like any other regression, instead of shipping and waiting for an audit.
Contrast gets checked when someone remembers to check it. That is the whole problem. It is a manual step at the end of a process, it competes with a deadline, and it loses.
The fix is to make it an automated assertion, which is how every other kind of regression is handled.
Why manual review fails
It happens too late. Contrast is typically reviewed near the end, when the colour is already load-bearing and changing it means reopening settled decisions.
It does not survive refactors. Someone nudges a grey one step lighter in the token file, six months after the audit, and nothing catches it.
It does not scale with themes. Light and dark double the surface area, and the dark side is where WCAG 2 is least trustworthy.
Nobody rechecks a design system. One under-contrast token propagates to every product consuming it.
Each of these is the signature of something that should be a test.
What to assert
Not every possible pair — the pairs you actually ship. For most systems that is a short list:
- Body text on the page background
- Body text on a card or raised surface
- Muted or secondary text on both
- Link colour on both
- Button label on button background, for each variant
- Border colour on its surrounding background, if the border identifies a control
Multiply by the number of themes. Twelve to twenty assertions covers most design systems.
A worked example
import { describe, expect, it } from 'vitest';
import { contrastRatio } from './wcag';
const LIGHT = {
ink: '#10141c',
inkSoft: '#2a313d',
muted: '#5a6473',
paper: '#f7f8fa',
card: '#ffffff',
accent: '#006ad5',
};
const PAIRS = [
{ name: 'body on paper', fg: LIGHT.inkSoft, bg: LIGHT.paper, required: 4.5 },
{ name: 'body on card', fg: LIGHT.inkSoft, bg: LIGHT.card, required: 4.5 },
{ name: 'muted on paper', fg: LIGHT.muted, bg: LIGHT.paper, required: 4.5 },
{ name: 'heading on paper', fg: LIGHT.ink, bg: LIGHT.paper, required: 3 },
{ name: 'button label', fg: '#ffffff', bg: LIGHT.accent, required: 4.5 },
];
describe('design tokens pass contrast', () => {
for (const p of PAIRS) {
it(`${p.name} clears ${p.required}:1`, () => {
const ratio = contrastRatio(p.fg, p.bg);
expect(ratio, `${p.name}: got ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(p.required);
});
}
});
The failure message matters. expected 4.21 to be greater than or equal to 4.5 sends someone hunting. muted on paper: got 4.21:1 tells them exactly which token to change.
Add the APCA assertion for anything small
WCAG 2 cannot see typography, so a passing ratio on a 12px label is not evidence. If you care about the reading experience rather than only the compliance number, assert APCA too — and hold different text roles to different thresholds.
APCA asks Lc 90 for columns of body text and Lc 60 for content and subheads. Applying the body threshold to a 13px label would force it to near-black and collapse your hierarchy, which helps nobody. Assert the standard that actually applies to each role.
What we found doing this
We added this to our own site after building the tool, and expected it to pass. It found seven APCA failures in chrome that passed WCAG 2 everywhere. The worst was our dark-mode link colour at Lc 37 against a requirement of 75 — genuinely hard to read, and invisible to every ratio-based check.
Two of the fixes were typographic rather than chromatic. Our 11px labels could not pass at any contrast, so they became 13px at weight 500. Our button labels could not pass white-on-accent at 15px, so they became 17px at weight 600.
Neither of those is a fix you would arrive at by nudging a hex value.
Deriving the fix instead of guessing
When an assertion fails, the temptation is to darken the colour until it passes. That is how palettes drift muddy over time.
Better to compute the minimum change: hold hue and chroma, search the lightness axis for the nearest value that clears the threshold. You get the smallest visual change that solves the problem rather than an arbitrary one, and the colour stays recognisably itself.
We generated every corrected value in our own palette this way rather than picking by eye. Several of the changes were under ΔE 1 — imperceptible, and the difference between failing and passing.
The cultural part
The reason this works is not technical. It is that a failing build is a shared, unambiguous signal, whereas an accessibility review is one person’s opinion competing with a ship date.
Once contrast is a test, nobody has to argue for it. It is just red, and red gets fixed.
Our contrast checker is the interactive half of this — when a test fails, it gives you the nearest passing colour to paste back into the token file.