Back to notes

CSS contrast-color(): text that picks its own color

24 October 2026·
csscoloraccessibilitycontrastcolor
Live demoOpen full screen

You give CSS a background color and it hands back white or black, whichever reads better on it. That's contrast-color(), and since April 2026 it works in Chrome, Firefox and Safari.

Play with the demo above for a minute. The big card has no text color set anywhere: drag the lightness slider, or hit Sweep, and watch the text flip from white to black at the point where black starts winning. The strip of twelve tiles below is the same idea at a glance: same lightness and chroma, twelve hues, and each tile flips at a different spot because yellow and blue don't have the same luminance at the "same" lightness.

What it is and why it matters

Any time a color comes from data (a tag color from a CMS, a user-picked theme, a chart series, a team's brand color) you need a text color that stays legible on top of it. Until now that meant a JavaScript helper, a Sass function at build time, or a lookup table of hand-checked pairs. contrast-color() moves that decision into the browser, at computed-value time, so it also follows custom properties, :hover states and theme switches.

There's a real example in this very repo. The active tag chip in the notes explorer on this site uses bg-richi-orange text-white, which is white on #FF5200. By the WCAG 2.1 formula that pair gives 3.25:1; black on the same orange gives 6.46:1. contrast-color(#FF5200) returns black. The demo starts on that exact orange so you can see it.

How it works

The minimal version is three lines, and it's what the demo card uses:

.badge {
  --bg: #FF5200;
  background: var(--bg);
  color: contrast-color(var(--bg));
}

The CSS Color Level 5 spec is short on purpose. The function takes one <color> and resolves to either white or black, whichever produces maximum contrast with the input used as a solid background. If both tie, it returns white. That's it: no target ratio, no list of candidates, no custom colors. The spec's changelog records that the function was simplified by a working group resolution, and a note says future versions are expected to add more control over the algorithm and the returned color.

Two details from the spec are worth knowing:

  • The algorithm is up to the browser. The spec says the exact contrast algorithm is UA-defined and advises browsers not to simply use the WCAG 2.1 contrast ratio, because of its known issues. It does require that the result still meets WCAG 2.1 AA for large text (3:1).
  • It decides; it doesn't guarantee. It picks the better of two options. It can't make a bad background good.

Out of curiosity I checked whether today's engines follow WCAG anyway. In Chromium 153 and in WebKitGTK 2.52, I compared contrast-color() against the WCAG 2.1 "pick the higher ratio" rule on 2,000 random sRGB colors, and the picks matched every time in both. That's a test, not a promise: the spec allows it to change.

The mid-tone trap

Here's the catch MDN warns about: on mid-tones, neither white nor black is great. MDN's own example is #2277d3, a royal blue that gets black text. By the WCAG formula that's 4.65:1 for black and 4.52:1 for white: both technically pass AA for normal text, both are uncomfortable for small type.

There's a hard floor here too. Doing the math with the WCAG luminance formula, the best of black or white can never drop below √21 ≈ 4.58:1. So contrast-color() will never fail AA outright, but on mid-tones it will hover right on that line. The demo shows both ratios live and tells you when you're above 7:1 (AAA) or only just in AA territory. The fix is still a design one: push the background lighter or darker.

Support, fallbacks and one gotcha

Support according to MDN's browser compat data: Chrome 147, Firefox 146 and Safari 26. web-features marks it Baseline newly available since April 10, 2026. I ran the demo in Chromium 153 and WebKitGTK 2.52; I couldn't run Firefox in my environment.

For older browsers, the classic "declare a fallback first" trick has a trap when you use a custom property. This doesn't work the way you'd expect:

.badge {
  color: black;
  color: contrast-color(var(--bg)); /* parses fine because of var() */
}

Because the value contains var(), the browser accepts the second declaration at parse time without checking the function. If it then can't resolve contrast-color(), the property becomes invalid at computed-value time and falls back to the inherited color, not to your black. I tested this in Chromium with a made-up function name and that's exactly what happens. Use @supports instead:

.badge { color: black; }

@supports (color: contrast-color(red)) {
  .badge { color: contrast-color(var(--bg)); }
}

And if you need the same decision in JavaScript (the demo does this as plan B when CSS.supports('color', 'contrast-color(red)') is false), the WCAG 2.1 version is short:

const lin = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);

function luminance(hex) {
  const [r, g, b] = [1, 3, 5].map((i) => lin(parseInt(hex.slice(i, i + 2), 16) / 255));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrastRatio(a, b) {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

// Same rule as contrast-color(): the one with more contrast wins, ties go to white
function pickTextColor(bg) {
  return contrastRatio(bg, '#000000') > contrastRatio(bg, '#ffffff') ? 'black' : 'white';
}

pickTextColor('#FF5200'); // "black"

What to keep an eye on

  • The next level of the function. The spec already says more control over the algorithm and the returned colors is expected. If you need brand colors instead of pure black and white, that's where it would show up.
  • The algorithm itself. Since it's UA-defined and the spec discourages plain WCAG 2.1, a browser could switch to a perceptual model. Mid-tone backgrounds are where you'd notice.
  • Your own audits. Automated checkers compute WCAG 2.1 ratios. If a browser ever picks differently from WCAG on a mid-tone, the checker and the screen could disagree.

Sources