The Color Contrast Problem That Won't Go Away
For years, the web has been stuck with a stubborn accessibility stat: roughly 70% of websites fail basic WCAG contrast checks. That number comes from the HTTP Archive Web Almanac, which has tracked the figure for years with little movement. The WebAIM Million paints an even bleaker picture — 83.9% of homepages flagged for low contrast text in 2026, up from 79.1% in 2025. Despite design system tooling, accessibility linters, and JavaScript libraries purpose-built for computing readable text colors, the needle barely shifts. On one benchmark, progress is a few percentage points per year; on another, the problem actually worsens.
Runtime JavaScript for something this fundamental doesn't scale across the open web. The CSS contrast-color() function is the alternative: one declaration lets the browser run the contrast math during style computation, before paint, returning the correct text color. No library, no build step, no hydration flash.
Note: If you've seen this called color-contrast() in older articles or spec drafts, that name was changed — the old syntax no longer works in any browser.
Level 5: The Simple Version
The Level 5 version is straightforward: you give it a color, and it returns black or white, whichever has higher contrast against your input.
.button {
background-color: var(--brand-color);
color: contrast-color(var(--brand-color));
}
Change --brand-color to neon green and text goes black. Change it to midnight navy and text goes white. Swap themes at runtime via JavaScript and the text adapts instantly — no event listeners, no recalculation.
A few limitations of the current version:
- It returns a
<color>, not a number — you get an actual color value you can use anywhere CSS accepts a color. - Black or white only, for now. Candidate color lists and target ratios are planned for Level 6.
- No keywords. If you've seen
maxin older blog posts, it was stripped from the spec. Using it will silently break your declaration. - As mentioned, the function was once
color-contrast(), but the CSSWG renamed it to follow the convention that CSS functions are named for what they return.color-mix()returns a color;contrast-color()returns a color. The old name sounded like it returned a contrast ratio (a number like 4.5), which was misleading. Tutorials from 2021–2023 showingcolor-contrast()syntax won't work in current browsers.
Level 5 Versus Level 6
CSS Color Level 5 defines what browsers ship today: one color in, black or white out. The algorithm is deliberately marked "UA-defined," meaning the browser decides what math to use internally. Currently, every engine uses WCAG 2.x relative luminance. That label isn't accidental — it's a planned escape hatch.
APCA (Accessible Perceptual Contrast Algorithm) often comes up in this context. It models how human eyes perceive contrast, factoring in font weight, spatial frequency, and ambient light — an improvement over the WCAG 2.x formula. By not locking "use WCAG 2.x" into the Level 5 spec, browser vendors could switch to APCA later without breaking existing code. Had the spec shipped with a wcag2() keyword as the default, every site using it would be stuck on the old math permanently.
APCA's future, however, is less certain than the hype suggests. Adrian Roselli's "WCAG3 Contrast as of April 2026" shows APCA was pulled from the WCAG 3 working draft in mid-2023 after failing to gain enough Working Group support. The WCAG 3 spec currently says the contrast algorithm is "yet to be determined," and the standard itself may not be finalized until 2030 or later. Roselli also filed a Chromium issue in May 2024 asking for the "Advanced Perceptual Contrast Algorithm" experiment flag to be removed from DevTools entirely, arguing the implementation is outdated and risks misleading developers into thinking APCA is further along — or more official — than it actually is. That issue remains open.
This doesn't mean APCA is dead. The research behind it is peer-reviewed and substantive, and its creator has noted that colors passing APCA guidelines greatly exceed WCAG 2 minimums in most cases. But there's currently no guarantee APCA will be the algorithm that replaces WCAG 2.x. If a different algorithm wins out, the "UA-defined" label lets browsers adapt without breaking your code. The Level 6 features — candidate color lists, target ratios, and the tbd-fg/tbd-bg keywords — are all designed around an algorithm that may or may not materialize in its current form.
CSS Color Level 6 adds the extended syntax — candidate color lists and target contrast ratios:
/* Level 6 future syntax — not shipping yet */
color: contrast-color(var(--bg) tbd-bg wcag2(aa), #1a1a2e, #e2e8f0, #fbbf24);
The browser would evaluate each candidate left to right, picking the first that meets the 4.5:1 AA threshold. The tbd-fg and tbd-bg keywords indicate whether the base color is foreground or background, which matters for directional contrast models like APCA. This is all Working Draft territory — doubly so given APCA's uncertain status. Use the Level 5 version for now.
Browser Support and Progressive Enhancement
Support is in better shape than most new CSS features. All three major engines ship it in stable releases: Chrome 147 (April 2026), Firefox 146, and Safari 26.0. It reached Baseline Newly Available status in April 2026. All three engines pass the Web Platform Tests for contrast-color(), so edge cases (tie-breaking logic, color space conversion, syntax parsing) behave consistently across browsers. See caniuse for the full version matrix.
The raw global support percentage on caniuse looks low, but that mostly reflects enterprise browsers and users who never update. Progressive enhancement works cleanly with @supports:
.card {
background: var(--bg);
color: #fff;
text-shadow: 0 0 4px rgb(0 0 0 / 0.8);
}
@supports (color: contrast-color(red)) {
.card {
color: contrast-color(var(--bg));
text-shadow: none;
}
}
Older browsers get white text with a dark shadow for legibility. Supporting browsers get the native calculation. Nobody sees broken text.
One caveat: automated accessibility scanners (Lighthouse, Axe, and similar) can't evaluate text-shadow. They only look at the computed color against background-color. So the fallback will still be flagged as a contrast failure in CI/CD pipelines, even if the shadow makes text perfectly legible to human eyes. Teams running automated a11y checks may need to allowlist that rule or add a comment explaining the flag is a false positive.
A note on PostCSS:
The plugin@csstools/postcss-contrast-color-functionevaluatescontrast-color()at build time. It works for static colors likecontrast-color(#ff0000), but the moment you use a custom property —contrast-color(var(--bg))— the plugin can't help because it has no access to runtime values. If your theming is dynamic (the whole point of doing this), skip the polyfill and rely on@supports.
Edge Cases and Behavioral Quirks
The function returns mathematically optimal results, but that doesn't mean it produces perceptually ideal ones. WCAG 2.x relative luminance has known blind spots — #2277d3 passes AA with black text at roughly 4.58:1, yet human eyes may still struggle. contrast-color() guarantees mathematical compliance, not perceptual accessibility. That distinction matters for anyone aiming beyond AA: for backgrounds with luminance between approximately 10% and 30%, neither black nor white reaches the 7:1 AAA threshold. In that dead zone, the function simply picks the least-bad failing option.
Transitions expose another limitation. The animated background in the example below fades smoothly, but the text color — a discrete black or white value — cannot interpolate:
.btn {
background-color: #fff;
color: contrast-color(#fff); /* black */
transition: background-color 1s, color 1s;
}
.btn:hover {
background-color: #000;
color: contrast-color(#000); /* white */
}
The snap doesn't occur halfway through the animation either. Unlike the HSL lightness midpoint at 50%, the WCAG tipping point where both colors tie sits at approximately 17.9% relative luminance. During a white-to-black background fade, text stays black for nearly the entire duration, then hard-cuts to white at the very end — a jarring effect. transition-behavior: allow-discrete doesn't solve this; it only shifts the snap to the animation's 50% mark. For smooth text-color transitions, layer color-mix() or handle the crossfade manually.
A few other behaviors worth noting:
- Tie goes to white. For a perfect middle gray, the spec hardcodes white as the winner.
- Gradients and images are rejected. The function accepts only a flat
<color>. Passing a gradient orurl()is a parse error. - Transparency is composited first. Semi-transparent colors are blended against an assumed opaque canvas (usually white) before contrast math runs — not ignored.
- Forced-colors mode overrides everything. Under
forced-colors: activein Windows High Contrast, system colors likeCanvasTexttake over andcontrast-color()bows out automatically.
Enriching the Black/White Output
A binary choice is limiting, but feeding that output into other color functions builds a full component palette from one custom property.
Pure white text on vibrant backgrounds can feel flat. Instead of generic black or white, use relative color syntax to inject the background's own hue into the contrast color:
.card {
--bg-hue: 260; /* Indigo */
--bg: oklch(0.6 0.1 var(--bg-hue));
background: var(--bg);
/* Pull L from the black/white contrast color,
but inject subtle chroma and the background's hue */
color: oklch(from contrast-color(var(--bg)) l 0.05 var(--bg-hue));
}
When contrast-color() returns white, l resolves to full lightness; when black, it drops to zero. Pulling the background's hue back in with a touch of chroma yields text that reads as deep indigo or pale icy indigo rather than neutral black/white. This differs from approximation strategies that replicate the binary decision in unsupported browsers — here, you accept the native output and give it personality.
Caution: Adjusting lightness or chroma can push a borderline ratio into failing territory. Validate tinted output with an accessibility linter. Also note this chains two modern features — if either contrast-color() or oklch(from ...) lacks support, the entire declaration fails. Test both in your @supports block:
@supports (color: contrast-color(red)) and (color: oklch(from red l c h)) {
/* Safe to use both */
}
A simpler softening technique mixes the sharp output back into the background:
.alert {
--bg: var(--alert-color);
background: var(--bg);
/* 80% contrast, 20% background = softer but readable */
color: color-mix(in oklch, contrast-color(var(--bg)) 80%, var(--bg));
/* 40% contrast for a subtle border */
border: 1px solid
color-mix(in oklch, contrast-color(var(--bg)) 40%, var(--bg));
}
This pattern drives text, borders, and even shadows from a single property like --alert-color — change one value and the whole component recalculates. It works particularly well for placeholder text, which should read clearly yet recede visually:
input {
--bg: var(--input-bg);
background: var(--bg);
color: contrast-color(var(--bg));
}
input::placeholder {
color: color-mix(in oklch, contrast-color(var(--bg)) 50%, var(--bg));
}
A 50% mix yields muted but legible placeholder that adapts to any underlying background.
For system light/dark support, the function pairs naturally with light-dark():
:root {
color-scheme: light dark;
--surface: light-dark(#fff, #121212);
}
.component {
background: var(--surface);
color: contrast-color(var(--surface));
}
Switching the OS to dark mode resolves --surface to #121212, and contrast-color() returns white — no media queries or JavaScript theme detection required.
What You Can Remove From Your Stack
The practical benefit: any library that existed solely to pick readable text colors can now be dropped from the runtime. You might still need JavaScript for generating complex color scales, but contrast selection is native.
The performance win goes beyond bundle size. Those libraries execute on the main thread — parsing colors, computing luminance, and writing results back to the DOM on every theme change or component mount. contrast-color() moves that work into the browser's native style computation, heavily optimized code that runs before paint. For applications with many themed components, the difference in responsiveness is real.
There's also a subtle bug eliminated: hydration flash. In server-side-rendered React or Vue apps, the server emits HTML without JavaScript; the client then hydrates and runs contrast calculations, leaving text invisible or wrongly colored between initial paint and hydration. CSS-based contrast resolves this during the first paint, before JavaScript loads.
Legacy Approaches Replaced
What this function supersedes:
- Compile-time Sass. A
lightness($bg) > 50%check returned black or white during build — fine for static themes, useless for user-picked colors or runtime dark mode. - CSS variable hacks. Splitting colors into
--r,--g,--bchannels, calculating Rec.709 luminance insidecalc(), and clamping results with negative infinity — functional but unreadable and fragile. OKLCH-based approximations improved on the math but remained workarounds.
contrast-color() replaces both with a single function call. Because the spec allows browsers to swap the underlying algorithm later, your code stays valid if APCA or another successor to WCAG 2.x math arrives.
The persistent 70% contrast failure rate was never about indifference — it reflected the distance between caring and shipping. Each intermediate step — a library, a build step, a runtime calculation, a hydration delay — was a place where accessibility silently dropped out. contrast-color() shortens that chain dramatically.




