A Practical Alternative to contrast-color()

Picking a readable foreground color for a user-configurable background is a common need, and CSS has been working toward a dedicated function for it. The contrast-color() function (previously drafted as color-contrast()) is still stuck in the CSS Color Module Level 5 spec, with only Safari and Firefox shipping support so far.

In the meantime, newer CSS features give us another path. Using color spaces and a few math functions, we can approximate the same result today in a way that works across browsers.

Recreating WCAG Luminance Math

WCAG has a well-defined formula for calculating contrast between RGB colors, based on luminance—a measure of how perceptually bright a color is. For deciding between white and black text, the full contrast calculus can be simplified to a luminance-based decision.

The luminance calculation itself is verbose in raw CSS, but tools like round() and the relative color syntax keep it manageable. The full expression ultimately returns 255 or 0 for each RGB channel, effectively swapping white for black based on WCAG 2.x contrast rules.

The result is functional but ugly. It's hard to read, harder to maintain, and tied to a formula that may soon be replaced. APCA, the proposed successor in WCAG 3, is even more intricate—an impractical fit for hand-written CSS.

Using Perceptual Lightness Instead

A different angle: instead of computing full luminance contrast, use perceptual lightness directly. The L* value in the CIELAB color space is designed to approximate how light a color looks to a human eye. If a background is bright enough, black text wins; below a certain threshold, white text is better.

The crossover point is not at 50%. Testing with a range of hues, the transition where black starts to beat white lands between 60–65% lightness in LCH. For oklch(), the equivalent range is between .65 and .72.

This makes for a much shorter implementation:

  • At OKLCH lightness of .72 or higher, black always has better contrast.
  • Below .65, white always wins.
  • In that mid-range, both options typically land between 45–60 APCA contrast.

The formula pivots on a constant. Using 1.21 as an offset ensures that a lightness of .72 rounds down (to black) while .71 rounds up (to white). This version is far easier to read than the WCAG-derived math, and it aligns better with APCA's perceptual model. It can disagree with WCAG 2.x in edge cases—for instance, on a mid-blue like #407ac2, the simpler formula picks white while WCAG rates black slightly higher—so legal compliance with WCAG still requires a manual check.

Why OKLCH Is the Right Choice

Running the same analysis for LCH reveals why OKLCH is preferable. The zone of ambiguity—where neither black nor white strongly wins—is broader and more variable in LCH. For a range of purple tones, LCH shows lightness values from 63 through 70 as ambiguous, while OKLCH narrows that to .7 through .77. The OKLCH scale simply maps better to APCA's perception of contrast.

Going Beyond Black and White

Both the spec's contrast-color() and the simplified formula above are locked into picking between black and white. The same decision logic can be extended to switch between white and any base color, using color-mix() as a multiplier.

The trick relies on a custom property that holds either white or black. When it's white, the mixed result is at least rgb(127.5, 127.5, 127.5); doubling that yields solid white. When it's black, the mix halves each channel and doubling restores the original base color. The math is clever but not transparent.

There are trade-offs. This technique requires Chrome, Firefox, or Safari 18+; older Safari versions will need a fallback to the simpler white-or-black version. CSS Custom Functions could tidy this up into reusable, named logic, but browser support is not yet universal.

A Flexible Approach

The main goal of this threshold-based method is adaptability. The exact cutoff can be tuned to match a specific design system or accessibility target. The result isn't a drop-in replacement for the future CSS function, but it handles the core case—legible text on an arbitrary background—with code that is short enough to actually maintain.