Why does a contrast check have to resolve colour in a browser?
A contrast audit that reads stylesheets is measuring a stylesheet, not a page, and an audit that skips an element it cannot parse reports success on exactly the elements that are broken. Both failures are silent, so both produce a green build on a page a human cannot read.
This is not a hypothetical about exotic CSS. The moment a colour arrives through a design token, the source text stops containing a colour at all. My own token pipeline emits --color-ink-muted: var(--color-ink-strong) and --color-accent: oklch(0.62 0.14 250), and Tailwind v4 writes its default palette in oklch() too. A checker that greps for #rrggbb finds nothing to test and reports no problems, which is the worst possible answer: not "I do not know", but "nothing is wrong".
A contrast check is only as trustworthy as its worst parse: an audit that skips the colour notations it does not understand returns a green build for a page whose text is invisible, so the only correct behaviour on unknown notation is to stop and fail the run.
Everything below is how I build a check whose failure is loud.
What does a stylesheet-level check actually miss?
The table below is the taxonomy I use to decide whether a finding is a real defect or a limitation of the method. The interesting column is the third one, because it is where a static checker says something confident and wrong.
| What the source says | What the page resolves to | What a static checker concludes |
|---|---|---|
color: var(--color-ink-muted) | a value two aliases deep, per theme | nothing to check |
color: color-mix(in oklab, var(--ink) 70%, transparent) | a blend against the surface behind it | nothing to check |
color: oklch(0.62 0.14 250) | an engine-dependent serialisation | unknown notation, skipped |
color: currentColor on a caption span | the inherited colour of its ancestor link | nothing to check |
color: rgba(15, 23, 42, 0.55) on a white card | rgb(123, 127, 138) after compositing | the opaque colour, 17.85:1, passes |
background: rgba(15, 23, 42, 0.72) over a photo | depends on the pixels behind it | passes against white |
.dark overrides on the same element | the dark value, at runtime | the light value, passes |
opacity: 0.4 on the parent block | 40% text over the surface | passes at full opacity |
Two rows deserve emphasis. An alpha channel is not a colour. Muted body text declared as rgba(15, 23, 42, 0.55) on a white surface composites to rgb(123, 127, 138), which is 3.99:1 against that white: below the 4.5:1 requirement for body text, above the 3:1 one for large text. Read the same declaration as if it were opaque and it reports 17.85:1, so the alpha alone is the difference between a comfortable pass and a failure. And opacity on an ancestor is the same arithmetic applied by the compositor rather than by the colour, which is why a checker that measures color and background-color in isolation can be arbitrarily wrong rather than slightly wrong.
Why must the resolution happen in the browser?
Because the cascade, the custom properties, the media queries, the container queries and the theme class are the program that produces the colour, and none of them are evaluated in a file. Reproducing that program in Node means porting var() resolution, specificity, @layer ordering and color-mix() — a reimplementation that will disagree with the browser the first time someone uses a feature you did not port, and will disagree silently.
I also do not want the audit's correctness to depend on how a given engine serialises a computed colour. The same declaration has come back to me as a legacy rgb() triple from one engine and as oklch(...) from another, which is my own observation across the two browsers I test in, not a specification I can rely on. So the parser accepts the notations I have verified and throws on everything else, which converts a new engine serialisation into a red build on the next run instead of an unchecked element.
How do you collect the effective colours from the page?
The page-side probe does no arithmetic and no parsing. It walks text-bearing elements and returns the raw computed strings, because every judgement — parsing, compositing, the ratio itself — then lives in Node where I can unit test it with fixtures. That split is what makes a parser with a throw in it testable.
// scripts/contrast-probe.ts — bundled by esbuild to an IIFE, then injected with
// Page.addScriptToEvaluateOnNewDocument so it exists before any page script runs.
export interface Sample {
path: string; // stable DOM path, so the report names the element
text: string;
color: string; // getComputedStyle().color, verbatim — never parsed here
backgrounds: string[]; // every ancestor background, element first, html last
}
const domPath = (el: Element): string => {
const parts: string[] = [];
for (let node: Element | null = el; node?.parentElement; node = node.parentElement) {
const parent = node.parentElement!;
parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${[...parent.children].indexOf(node) + 1})`);
}
return parts.join(" > ");
};
export function __contrastSamples(): Sample[] {
const samples: Sample[] = [];
for (const el of document.querySelectorAll<HTMLElement>("body *")) {
if (!el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
const own = [...el.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE).map((n) => n.textContent ?? "").join("").trim();
if (own.length < 2) continue;
const backgrounds: string[] = [];
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
backgrounds.push(getComputedStyle(node).backgroundColor);
}
samples.push({ path: domPath(el), text: own.slice(0, 80), color: getComputedStyle(el).color, backgrounds });
}
return samples;
}
Three details carry weight. checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) is doing the work that display: none cannot: a span inside a hidden container reports display: inline from getComputedStyle, so checking the element's own display misses exactly the elements that are not rendered. Only the element's own text nodes are measured, which handles <p>Some text with a <strong>link</strong> inside</p> as three samples rather than as one skipped paragraph with element children. And the probe returns the whole ancestor background stack rather than a pre-resolved winner, so the compositing decision stays in one testable place. Because the file is TypeScript it is built before injection: esbuild scripts/contrast-probe.ts --bundle --format=iife --global-name=probe --footer:js='window.__contrastSamples=probe.__contrastSamples'. The runner passes that bundle to Page.addScriptToEvaluateOnNewDocument, so the probe exists before any page script.
What should the parser do with notation it does not recognise?
Throw, record the element, and fail the run. There is no fallback branch, no return null, and no catch that turns an unknown colour into a warning, because that branch is precisely the mechanism by which an audit lies.
// lib/colour/parse.ts — one entry point, exhaustive by construction.
export interface Rgba { readonly r: number; readonly g: number; readonly b: number; readonly a: number }
export class UnparsedColourError extends Error {
constructor(readonly notation: string) {
super(`Unparsed colour notation: ${notation}`);
this.name = "UnparsedColourError";
}
}
const HEX = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/;
const RGB = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/;
export function parseColour(computed: string): Rgba {
const value = computed.trim().toLowerCase();
if (value === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
const hex = HEX.exec(value);
if (hex) {
const digits = hex[1].length <= 4 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
const int = Number.parseInt(digits.slice(0, 6), 16);
const a = digits.length === 8 ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1;
return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255, a };
}
const rgb = RGB.exec(value);
if (rgb) {
const raw = rgb[4];
const a = raw === undefined ? 1 : raw.endsWith("%") ? Number.parseFloat(raw) / 100 : Number.parseFloat(raw);
return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]), a };
}
throw new UnparsedColourError(value);
}
The set of notations this handles is deliberately small: the keyword transparent, #rgb through #rrggbbaa, and rgb()/rgba() with either comma or space separators and alpha as a number or a percentage. Anything else — oklch(), lab(), color(srgb 0.1 0.2 0.3), color-mix(), or a named colour like red — throws. Named colours throwing is a feature, not a gap: getComputedStyle does not return red, so if I ever see one, I am reading something that is not a computed value, and I want the run to stop and tell me that rather than guess.
The only catch for UnparsedColourError is in the report writer, and it does not continue. It records the DOM path, the element's text, and the offending string into .contrast/unparsed.json, prints the count, and exits non-zero. On the first run against a real site that file is the most useful artefact the audit produces, because it is an inventory of everything the CSS is doing that the checker does not model.
What does the ratio require before it is valid?
Two things: opaque inputs, and the correct transfer function. The WCAG 2.x ratio is defined on relative luminance, with the sRGB linearisation and coefficients below, and it is undefined on a colour with an alpha channel. Measuring a semi-transparent colour directly, without compositing it first, is the bug that makes dark overlays pass against a white page.
// lib/colour/contrast.ts
import type { Rgba } from "./parse";
/** Source-over compositing: `top` painted over an opaque `bottom`. */
export function over(top: Rgba, bottom: Rgba): Rgba {
const a = top.a + bottom.a * (1 - top.a);
if (a === 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (t: number, b: number): number => (t * top.a + b * bottom.a * (1 - top.a)) / a;
return { r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b), a };
}
/** sRGB transfer function. 0.04045 is the breakpoint most implementations use. */
const linear = (channel: number): number => {
const s = channel / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
export const luminance = ({ r, g, b }: Rgba): number =>
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
/** Both arguments must be opaque. Composite the background stack before calling this. */
export function contrastRatio(foreground: Rgba, background: Rgba): number {
const a = luminance(foreground);
const b = luminance(background);
const [hi, lo] = a > b ? [a, b] : [b, a];
return (hi + 0.05) / (lo + 0.05);
}
The background resolution is a short fold: reverse the ancestor stack the probe returned so html comes first, seed the accumulator with white (the default canvas, since html frequently reports transparent), and composite each entry over it. The fold can stop at the first opaque layer, because nothing below it changes the result.
The thresholds themselves are not mine to choose. 4.5:1 for body text and 3:1 for text at 24px, or 18.66px when bold, are the numbers procurement and legal review recognise, so they are the gate. I treat the newer perceptual models as a second signal rather than a replacement, because a build that fails on a metric nobody downstream has agreed to accept is a build somebody will disable.
How does it become something CI cannot ignore?
It runs against a built server, never a dev server: dev mode serves different CSS, and an error overlay injects text nodes that produce findings against your own tooling.
set -euo pipefail
pnpm build
pnpm start -- -p 4321 &
SERVER_PID=$!
trap 'kill "$SERVER_PID"' EXIT
npx wait-on --timeout 60000 http://127.0.0.1:4321/en
# Fails on: a ratio below threshold, any unparsed notation, an expired waiver,
# or fewer than 200 sampled text nodes on a page (the shape of a silent no-op).
node scripts/contrast-audit.mjs \
--base http://127.0.0.1:4321 \
--min-samples-per-page 200 \
--waivers .contrast/waivers.json \
--json .contrast/report.json
The sample floor is the part people leave out and it is the part that matters. A refactor that renames a wrapper class can make the selector match nothing, and an audit that samples zero elements has zero violations. A floor of 200 text nodes per page is low enough that any real article clears it by a wide margin and high enough that a selector change returning nothing trips the job. Waivers are a JSON file keyed by DOM path with a mandatory expires date; a waiver past its date is itself a failure, which is the only way I have found to stop an exception becoming a policy.
The last thing to remove is the habit of tolerating it. A gate that fails on changes nobody believes are wrong gets routed around within a quarter, which is the same seconds-per-task arithmetic that decides whether anyone keeps using a tool they were handed. If the audit is noisy, the fix is to fix the parser or write a dated waiver, not to add continue-on-error: true.
When is a browser-rendered contrast audit the wrong approach?
Whenever the colour is not reachable through the DOM. Text painted into a <canvas>, a WebGL scene, or a rasterised image has no computed colour at all, and the honest output for those regions is unmeasurable, counted separately and reviewed by a person. I have made the mistake of treating unmeasurable as passing, and it is the same error as skipping an unparsed notation with better manners.
Gradients and background images are the harder case. linear-gradient(...) as a background-image means the effective background is a range rather than a colour, so a single ratio is meaningless. I report the ratio against both the lightest and the darkest stop and let a human decide whether the text crosses the midpoint. Text over video is the same problem with more variance, and no threshold you compute will be honest.
Cost is the second limit. Each page needs a real render, an element walk, and a getComputedStyle call per ancestor per sample, and the walk forces style recalculation. On this site that is about seventy pages, a little over a minute of added CI time, and I consider that cheap. On a site with several thousand routes it is not, and the correct move is to audit one page per route template plus the routes a pull request changed, rather than every URL. That trade costs you the guarantee, so it should be a decision you write down rather than a default you discover.
There is also a case for not building this at all. If axe-core is already in your pipeline, its contrast rule covers much of the same ground. But axe returns incomplete rather than violation for elements whose background it cannot resolve — over a gradient, or an image — and a workflow that fails only on violations reads that incomplete bucket as a pass. That is the whole failure mode this article is about, arriving through a tool you already installed. Check the bucket before you assume you are covered.
What to do first
Run the browser audit against your two most complicated pages before you wire anything into CI, and read the unparsed-notation list it prints — that list is a more accurate inventory of what your CSS is doing than most stylesheets are. Then set the sample floor, write the waivers file with expiry dates, and delete any continue-on-error from the workflow. The first run almost always finds one element with unreadable text; the last one I fixed was a muted caption sitting at 3.1:1 on a raised surface, in a component that had passed review twice. A contrast check is worth its minute of CI time on exactly one condition: that a broken page cannot pass it.
Keep reading
- Design Engineering: Closing the Figma-to-Production Gap Without a Handoff2026-03-047 minDesign Engineering
- CJK Typography on the Web: Why Tracking Breaks Chinese Text2026-03-039 minDesign Engineering
- Accessible Form Patterns Are Four Primitives, Not a Component Library2026-02-259 minDesign Engineering