You have an element with a configurable background color and you want to calculate whether the foreground text should be light or dark. Seems simple enough, especially knowing how conscious we have to be with accessibility.
There have been a few drafts of a specification function for this functionality, most recently, contrast-color() (earlier color-contrast()) in the CSS Color Module Level 5 concept. But since Safari and Firefox are the only browsers to implement this so far, the final version of this functionality is likely still a ways off. A lot of functionality has now been added to CSS; enough that I wanted to see if we could implement it in a browser-friendly way today. This is what I have:
color: oklch(from round(1.21 - L) 0 0); Let me explain how I got here.
WCAG 2.2
WCAG provides the formulas it uses to calculate the contrast between two RGB colors and Stacie Arellano has described this in detail. It is based on older methods, where the brightness of colors (how perceptually bright they appear) and even tries to take into account the limitations of monitors and screen flare:
L1 + 0.05 / L2 + 0.05ā¦where the lighter color (L1) is at the top. Luminance ranges from 0 to 1, and this fraction is responsible for contrast ratios ranging from 1 (1.05/1.05) to 21 (1.05/0.05).
The formulas for calculating luminance of RGB colors are even messier, but I’m just trying to determine whether white or black will have higher contrast for a given color, and I can get away with a little simplification. We end up with something like this:
L = 0.1910(R/255+0.055)^2.4 + 0.6426(G/255+0.055)^2.4 + 0.0649(B/255+0.055)^2.4Which we can convert to CSS as follows:
calc(.1910*pow(r/255 + .055,2.4)+.6426*pow(g/255 + .055,2.4)+.0649*pow(b/255 + .055,2.4))We can round this whole thing to 1 or 0 using round()1 for white and 0 for black:
round(.67913 - .1910*pow(r/255 + .055, 2.4) - .6426*pow(g/255 + .055, 2.4) - .0649*pow(b/255 + .055, 2.4))Let’s multiply that by 255 and use it for all three channels with the relative color syntax. We end with this:
color: rgb(from
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)
); A formula that, given a color, returns white or black based on WCAG 2. It’s not easy to read, but it works… except APCA is ready to replace it as a newer, better formula in future WCAG guidelines. We can do the math again, although APCA is an even more complicated formula. We could use CSS functions to clean it up a bit, but ultimately this implementation will be inaccessible, difficult to read, and difficult to maintain.
New approach
I took a step back and thought about what else we have available. We have another new feature to try out: color spaces. The āL*ā value in the CIELAB color space perceptual lightness. It is meant to represent what our eyes can see. It’s not the same as luminance, but it’s close. Perhaps we can guess whether to use black or white for better contrast based on perceptual lightness; Let’s see if we can find a number where for each color with a lower lightness we use black, and for a higher lightness we use white.
You might instinctively think it should be 50% or 0.5, but it isn’t. Many colors, even when bright, still contrast better with white than with black. Here are some examples of usage lch()which slowly increases the lightness while the hue remains the same:
The transition point where it is easier to read the black text than the white usually occurs between 60-65. So I put together a quick Node app using Colorjs.io to calculate where the border should be, using APCA to calculate the contrast.
For oklch()I found the threshold to be between .65 and .72, with an average of .69.
In other words:
- When the OKLCH lightness is 0.72 or higher, black will always contrast better than white.
- Below .65, white will always contrast better than black.
- Between .65 and .72, both black and white typically have contrasts between 45-60.
So just use it round() and the upper bound of .72, we can create a new, shorter implementation:
color: oklch(from round(1.21 - L) 0 0); If you’re wondering where 1.21 comes from, it’s that .72 is rounded down and .71 is rounded up: 1.21 - .72 = .49 is rounded down, and 1.21 - .71 = .5 rounds off.
This formula works quite well after a few iterations of this formula have been put into production. It is easier to read and maintain. That said, this formula matches APCA better than WCAG, so sometimes it disagrees with WCAG. For example, WCAG says black has a higher contrast (4.70 than white at 4.3) when placed on it #407ac2while APCA claims the opposite: black has a contrast of 33.9 and white a contrast of 75.7. The new CSS formula corresponds to APCA and is white:
This formula could undoubtedly do a better job than WCAG 2.0, because it better aligns with APCA. That said, you still need to check accessibility, and if you’re legally bound by WCAG rather than APCA, then this newer, simpler formula may be less useful to you.
LCH vs OKLCH
I ran the numbers for both, and aside from the fact that OKLCH is designed to be a better replacement for LCH, I also found that the numbers support OKLCH being a better choice.
With LCH, the gap between too dark for black and too light for white is often larger, and the gap moves more. For example, #e862e5 Through #fd76f9 are too dark for black and too light for white. At LCH this ranges from lightness 63 to 70; for OKLCH this is .7 to .77. The scaling of OKLCH lightness simply matches APCA better.
One step further
While ‘most contrast’ will certainly be better, we can implement one more trick. Our current logic simply gives us white or black (that’s what the color-contrast() feature is currently limited to), but we can change this to give us white or some other specific color. So for example white or the basic text color. Starting with this:
color: oklch(from round(1.21 - L) 0 0);
/* becomes: */
--white-or-black: oklch(from round(1.21 - L) 0 0);
color: rgb(
from color-mix(in srgb, var(--white-or-black), )
calc(2*r) calc(2*g) calc(2*b)
); It’s clever math, but it’s not pleasant to read:
- If
--white-or-blackis white,color-mix()results inrgb(127.5, 127.5, 127.5)or brighter; doubled where we arergb(255, 255, 255)or higher, which is just white. - If
--white-or-blackis black,color-mix()reduces the value of each RGB channel by 50%; doubled we are back to the original value of the.
Unfortunately, this formula doesn’t work in Safari 18 and below, so you’ll have to focus on Chrome, Safari 18+, and Firefox. However, it does give us a way to switch between white and a base text color using pure CSS, instead of just white and black, and we can fall back to white and black in Safari <18.You can also rewrite both of these using CSS Custom Functions, but these are not yet supported everywhere:
@function --white-black(--color) {
result: oklch(from var(--color) round(1.21 - l) 0 0);
}
@function --white-or-base(--color, --base) {
result: rgb(from color-mix(in srgb, --white-black(var(--color)), var(--base)) calc(2*r) calc(2*g) calc(2*b));
}Conclusion
I hope this technique works well for you, and I would like to reiterate that the purpose of this approach ā looking for a threshold and a simple formula ā is to make the implementation flexible and easy to adapt to your needs. You can easily adjust the threshold to what works best for you.
#Accessing #contrastcolor #CSS #functions #CSS #tricks


