"CSS Finally Learned to Pick Its Own Text Colors"
Seventy percent of websites still fail basic WCAG contrast checks in 2025. Not because developers don't care — because the distance between caring and shipping has always been littered with libraries, build steps, runtime calculations, and the one component someone forgot to wire up. Smashing Magazine's Durgesh Pawar recently made the case that we've been solving this problem in the wrong place for years. The fix, it turns out, belongs in the browser's style engine, not in a JavaScript bundle.
Enter contrast-color(), a CSS function that takes a background color and returns either black or white — whichever has more contrast. One declaration. No library. No build step. No hydration flash. The browser runs the contrast math during style computation, before the page paints, and hands you the right text color. Change --brand-color to neon green, text goes black. Change it to midnight navy, text goes white. Swap themes at runtime and the text adapts instantly, no event listeners required.
The distance between caring about contrast and shipping it was the problem — not the caring itself.
The function shipped in stable releases across all three major browser engines earlier this year: Chrome 147, Firefox 146, and Safari 26.0. It hit Baseline Newly Available status in April 2026. That means if you're reading this in a modern browser, contrast-color() already works. Progressive enhancement is straightforward — wrap it in @supports and give older browsers a sensible fallback with a text shadow or a static color.
What makes this more than just a convenience function is what it replaces. For years, developers reached for chroma-js (~14 kB), polished (~11 kB), or tinycolor2 (~5 kB) just to answer one question: should this text be black or white? Those libraries didn't just cost network bytes — they ran on the main thread, competing with layout and event handlers. In server-side rendered React or Vue apps, there was also the hydration flash: the server sends HTML without JavaScript, the page paints with the wrong text color, then the client hydrates and fixes it. contrast-color() eliminates that entire class of bugs by making the decision at paint time.
There's a deeper architectural insight here that goes beyond one function. The web platform spent two decades offloading layout and styling decisions to JavaScript because CSS couldn't compute them. Flexbox replaced jQuery layout plugins. color-mix() replaced Sass color functions. Grid replaced framework column systems. Each time, the pattern was the same: identify a computation that belongs in the rendering pipeline, move it there, and watch an entire category of bugs and performance problems evaporate. contrast-color() fits squarely into that lineage — it's not just a new function, it's the platform absorbing a responsibility it should have handled all along.
The Level 5 spec is deliberately minimal: one color in, black or white out. The algorithm is marked "UA-defined," meaning browser vendors can swap the underlying contrast math later without breaking anyone's code. That matters because WCAG 2.x relative luminance — the formula every browser currently uses — has known perceptual blind spots. A medium blue like #2277d3 mathematically passes AA with black text but can be genuinely hard to read. The Accessible Perceptual Contrast Algorithm (APCA) models how human eyes actually perceive contrast, factoring in font weight and spatial frequency, but its path into the WCAG 3 standard is uncertain. By not locking WCAG 2.x into the spec text, the CSS Working Group left the door open for whatever algorithm eventually wins out — and none of your existing contrast-color() calls will need to change.
But the function gets genuinely interesting when you stop treating its output as the final answer and start treating it as a building block. Feed contrast-color() into color-mix() and you can soften the harsh black-or-white binary into something that matches your brand. Mix 80% of the contrast color with 20% of the background, and text reads with personality instead of severity. Feed it into the Relative Color Syntax and you can pull the background's hue into the text, producing deep indigo-on-lavender or pale coral-on-cream instead of generic black and white. One custom property can now drive an entire component's text, border, placeholder, and shadow colors — all recalculating automatically when the background changes.
Combine it with light-dark() and system dark mode becomes a one-liner. Set --surface: light-dark(#fff, #121212) and color: contrast-color(var(--surface)) — no media queries, no JavaScript theme detection, no class toggling. The whole chain resolves natively. For apps that already support user-selected accent colors or CMS-driven brand palettes, contrast-color() closes the last mile of the accessibility pipeline: the part where someone on the marketing team picks a custom shade of yellow and suddenly nobody can read the button labels. Now the browser handles it silently, and the marketing team never even has to know there was a problem.
Key takeaways
contrast-color()ships in all major browsers, making contrast decisions native to the rendering pipeline- It eliminates entire categories of JS libraries, hydration bugs, and main-thread performance costs
- The UA-defined algorithm future-proofs your code — swap the contrast math later without touching your CSS
- Combined with
color-mix()and Relative Color Syntax, it becomes a theming engine, not just a contrast switch
There's a meaningful downstream effect on accessibility audits worth noting. Automated scanners like Lighthouse and Axe flag contrast failures in CI/CD pipelines, and those flags create real organizational friction — teams delay deploys, file Jira tickets, and sometimes simply disable the check. When contrast decisions move into CSS and resolve correctly at paint time without developer intervention, those false positives drop. The remaining failures are the ones that genuinely need human attention: complex backgrounds, images, gradients. That's a better allocation of everyone's time.
Not everything is solved, and the spec authors are refreshingly honest about the gaps. The function doesn't work with gradients or images — if your background is a photo, you still need manual intervention. It can't guarantee AAA compliance because there's a genuine dead zone where neither black nor white hits 7:1. CSS transitions snap instead of fading because the output is a discrete value. And automated scanners still can't evaluate text-shadow fallbacks, so your progressive enhancement might get flagged even when the text is perfectly legible. These are real limitations, but they're the right kind of limitations — acknowledged edges of a well-scoped tool, not hidden traps.
Stepping back, there's something quietly radical about a browser feature aimed at making the web more accessible by default rather than more capable. Most new CSS features ship because they let you do something you couldn't do before — container queries, view transitions, scroll-driven animations. contrast-color() ships because it makes doing the right thing cost nothing. That 70% failure rate was never about developers refusing to care about contrast. It was about the friction between caring and shipping. Remove the friction, and the care has room to matter.
Further reading: Smashing Magazine — Durgesh Pawar, "Algorithmic Theming Engines" (May 2026); Adrian Roselli — "WCAG3 Contrast as of April 2026"; caniuse — contrast-color() browser support
Comments
Leave a Comment