A CSS contrast color approximation that ships today

Dynamic background colors in WordPress themes usually come with one of two pieces of advice: wait for browser support, or ship a JavaScript library to do the math. Neither is great. One leaves readability broken for months, the other adds weight to every page load. After 14 years of WooCommerce checkouts and custom blocks, I would rather approximate the right answer in CSS than wait for the perfect one.

The function everyone is waiting on is contrast-color() from the CSS Color Module Level 5. Give it a background and it sets readable text for you automatically. So far only Safari and Firefox support it, which means anything shipping to real users still needs a CSS contrast color approximation. I assumed I had seen every workaround for this until I sat down with the relative color syntax.

Why WCAG math is painful in raw CSS

Porting the WCAG 2.1 or 2.2 luminance formula into raw CSS is possible, and it is horrible. The formula works on older RGB math that does not track how we actually perceive brightness, and once it is inlined into a property you get something like this, which nobody wants to debug six months later:

/* The "Don't Do This" approach: Messy RGB calculation */
color: rgb(from var(--bg-color)  
  round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
  round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
  round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
);

It also gets worse before it gets better. Future guidelines are expected to move to APCA (Advanced Perceptual Contrast Algorithm), which is more complex again. Hand-rolling that inside a calc() chain is not something I would put in a production stylesheet.

A practical CSS contrast color approximation with OKLCH

oklch() saves you from most of that. OKLCH is perceptually uniform in a way RGB and HSL are not, so its lightness channel lines up reasonably well with how bright a color looks to a person. That leaves an easier job: find the lightness value where text should flip between black and white, and compare against it.

I tested a spread of colors with Colorjs.io and landed on roughly 0.72 for that threshold. The whole thing then fits in one declaration:

/* The "Senior Dev" Fix: Perceptual Lightness Flip */
.dynamic-element {
  --bg: #407ac2;
  background: var(--bg);
  
  /* If L > 0.72, result is black. If L < 0.72, result is white. */
  color: oklch(from var(--bg) round(1.21 - l) 0 0);
}

All the work happens in 1.21 - l. When the background lightness is 0.72 or higher, the result rounds to 0 and you get black text. Below that, it rounds to 1 and you get white. One declaration replaces the entire RGB block above.

Going beyond black and white

Pure black is often too harsh. If you want the element to flip between white and your brand’s base text color instead, color-mix() gets you there. I use the same trick when styling search-text and other dynamic UI elements.

/* Advanced Switcher: White vs. Base Brand Color */
:root {
  --base-text: #2c3e50;
}

.card {
  --white-or-black: oklch(from var(--bg) round(1.21 - l) 0 0);
  
  color: rgb(  
    from color-mix(in srgb, var(--white-or-black), var(--base-text))  
    calc(2*r) calc(2*g) calc(2*b)  
  );
}

The mix runs at 50%. If --white-or-black came out white, doubling each channel with calc(2*r) pushes it to 255 and you get white. If it came out black, the mix halves the brand color and the doubling puts it back exactly where it started. It is a hack. It is also pure CSS, and it holds up in current Chrome, Firefox and Safari 18+.

If this kind of color plumbing is eating your week, I take on that work. I have been in WordPress themes since the 4.x days, and accessibility bugs in dynamic color are a familiar mess to me.

The takeaway for theme devs

The OKLCH threshold is the closest thing to contrast-color() you can ship right now, and it sits nearer to perceptual models like APCA than the old WCAG luminance formula does. It needs no JavaScript. If you maintain a block theme or a WooCommerce store where clients pick their own colors, put it in the global stylesheet and move on. We also covered the accessibility improvements in WordPress core if you want the wider picture.

author avatar
Ahmad Wael
I'm a WordPress and WooCommerce developer with 15+ years of experience building custom e-commerce solutions and plugins. I specialize in PHP development, following WordPress coding standards to deliver clean, maintainable code. Currently, I'm exploring AI and e-commerce by building multi-agent systems and SaaS products that integrate technologies like Google Gemini API with WordPress platforms, approaching every project with a commitment to performance, security, and exceptional user experience.