Skip to content
back to blog
developmentAugust 3, 2026 · 4 min read

CSS contrast-color(): Let the Browser Pick Your Text Color

The contrast-color() function went Baseline in April 2026 and kills the luminance-math helper every design system eventually writes. Here is how it works, the mid-tone problem nobody warns you about, and how to work around it.

Dan Holloran
Dan Holloran
Senior Frontend & Fullstack Developer
CSS contrast-color(): Let the Browser Pick Your Text Color

Every design system eventually grows the same ugly little function. Someone adds theme colors that users can pick, a marketing page ships category badges in eleven brand colors, and suddenly you need to know whether the label on top should be black or white. So you write it: relative luminance, the WCAG coefficients, a 0.179 threshold pulled from a blog post, and a unit test nobody trusts. Then you run it at build time, or worse, in a useEffect, and you cache the result in a CSS custom property.

contrast-color() deletes all of that. It shipped in Firefox 146 in December 2025, Chrome 147 in April 2026, and Safari 26.0, which made it Baseline Newly Available in April 2026. All three engines pass the Web Platform Tests for it, so the edge cases actually agree.

One function, no math

You hand it a color and it hands back either black or white, whichever has more contrast against what you gave it. That is the entire API surface.

css
.badge {
  background: var(--badge-color);
  color: contrast-color(var(--badge-color));
}

The real payoff shows up when the color is not yours to know at authoring time. Inline styles from a CMS, a user-chosen accent, a chart series colored by data — all the cases where you previously had to compute in JavaScript and pass a value down:

html
<span class="badge" style="--badge-color: #7c3aed">Design</span>
<span class="badge" style="--badge-color: #fbbf24">Ops</span>
<span class="badge" style="--badge-color: #0f766e">Platform</span>

Purple and teal get white text. Amber gets black. No script ran, no build step computed anything, and the value stays live if the custom property changes at runtime.

It composes with the rest of modern color CSS, too. Relative color syntax and contrast-color() in the same rule means a hover state that recolors itself correctly:

css
.button {
  --bg: oklch(0.55 0.18 265);
  background: var(--bg);
  color: contrast-color(var(--bg));
}

.button:hover {
  --bg: oklch(from oklch(0.55 0.18 265) calc(l + 0.12) c h);
}

Because --bg is what both declarations read, lightening the background can flip the text from white to black on its own.

The mid-tone problem

Here is the part that gets glossed over: the function only ever returns black or white, and it targets the WCAG 2 AA threshold of 4.5:1. Those two facts collide badly in the middle of the lightness range.

Take #767676. Against white it lands at roughly 4.5:1. Against black, roughly 4.7:1. contrast-color() will dutifully return black because black wins — but "wins" here means 4.7 against 4.5, and a background one notch lighter or darker flips the answer while both options sit barely at threshold. You get a technically-passing, genuinely hard-to-read label, and worse, a palette where two adjacent swatches suddenly have opposite text colors.

The function is not broken. It is doing exactly what it promises. The promise just does not cover mid-tones, because for a mid-tone background neither pure black nor pure white is a good answer. That is a palette problem, and CSS cannot fix a palette problem.

Guardrails worth adding

The practical fix is to keep contrast-color() for the ends of the range and stop your palette from producing mid-tones in the first place. Relative color syntax makes that cheap:

css
.badge {
  /* Push any input away from the muddy middle before contrasting against it. */
  --safe: oklch(from var(--badge-color) clamp(0.2, l, 0.45) c h);
  background: var(--safe);
  color: contrast-color(var(--safe));
}

Clamping lightness to a band you have actually tested means the returned black or white is comfortably past threshold, not scraping it. You give up some fidelity to the user's exact color — worth it when the alternative is unreadable text.

If you need to support anything older than late 2025, wrap the enhancement rather than betting on it:

css
.badge {
  color: #fff; /* tested against your clamped dark band */
}

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

Safari has also been experimenting with an extended syntax that lets you name the contrast algorithm rather than always getting the WCAG 2 default. That would address the mid-tone squeeze directly, since APCA models perceived contrast far better in that range, but it is not interoperable yet. Treat it as a thing to watch, not a thing to ship.

For the common case — dark brand colors, light surfaces, badges, buttons, tag pills — contrast-color() is a straight deletion. Find the luminance helper in your utils folder, check whether every background it serves lives at the ends of the lightness range, and if it does, delete the file.

~/subscribe
# new posts on code, craft & travel — no noise, no schedule
$subscribe