You’ve gotten a component with a configurable background coloration, and also you’d wish to calculate whether or not the foreground textual content ought to be mild or darkish. Appears simple sufficient, particularly realizing how conscious we should be with accessibility.
There have been just a few drafts of a specification operate for this performance, most not too long ago, contrast-color() (previously color-contrast()) within the CSS Colour Module Degree 5 draft. However with Safari and Firefox being the one browsers which have carried out it thus far, the ultimate model of this performance is probably going nonetheless a methods off. There was numerous performance added to CSS within the meantime; sufficient that I needed to see whether or not we might implement it in a cross-browser pleasant means right this moment. Right here’s what I’ve:
coloration: oklch(from spherical(1.21 - L) 0 0);
Let me clarify how I obtained right here.
WCAG 2.2
WCAG supplies the formulation it makes use of for calculating the distinction between two RGB colours and Stacie Arellano has described in nice element. It’s primarily based on older strategies, calculating the luminance of colours (how perceptually shiny they seem) and even tries to clamp for the constraints of screens and display screen flare:
L1 + 0.05 / L2 + 0.05
…the place the lighter coloration (L1) is on the highest. Luminance ranges from 0 to 1, and this fraction is chargeable for distinction ratios going from 1 (1.05/1.05) to 21 (1.05/.05).
The formulation for calculating the luminance of RGB colours are even messier, however I’m solely making an attempt to find out whether or not white or black can have increased distinction with a given coloration, and may get away with simplifying a bit of bit. We find yourself with one thing 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.4
Which we can convert into CSS like this:
calc(.1910*pow(r/255 + .055,2.4)+.6426*pow(g/255 + .055,2.4)+.0649*pow(b/255 + .055,2.4))
We will make this complete factor spherical to 1 or 0 utilizing spherical(), 1 for white and 0 for black:
spherical(.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 coloration syntax. We find yourself with this:
coloration: rgb(from
spherical(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)
spherical(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)
spherical(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 components that, given a coloration, returns white or black primarily based on WCAG 2. It’s not simple to learn, however it works… besides APCA is poised to interchange it as a more recent, higher components in future WCAG tips. We will do the mathematics once more, although APCA is an much more difficult components. We might leverage CSS features to scrub it up a bit of, however in the end this implementation goes to be inaccessible, onerous to learn, and tough to take care of.
New Strategy
I took a step again and thought of what else we’ve obtainable. We do have one other new function we are able to check out: coloration areas. The “L*” worth within the CIELAB coloration house represents perceptual lightness. It’s meant to mirror what our eyes can see. It’s not the identical as luminance, however it’s shut. Perhaps we might guess whether or not to make use of black or white for higher distinction primarily based on perceptual lightness; let’s see if we are able to discover a quantity the place any coloration with decrease lightness we use black, and better lightness we use white.
You would possibly instinctively assume it ought to be 50% or .5, however it isn’t. Loads of colours, even after they’re shiny, nonetheless distinction higher with white than black. Right here’s some examples utilizing lch(), slowly rising the lightness whereas retaining the hue the identical:
The transition level the place it’s simpler to learn the black textual content than white normally occurs between 60-65. So, I put collectively a fast Node app utilizing Colorjs.io to calculate the place the minimize off ought to be, utilizing APCA for calculating distinction.
For oklch(), I discovered the edge to be between .65 and .72, with a median of .69.
In different phrases:
- When the OKLCH lightness is .72 or above, black will at all times distinction higher than white.
- Beneath .65, white will at all times distinction higher than black.
- Between .65 and .72, usually each black and white have contrasts between 45-60.
So, simply utilizing spherical() and the higher sure of .72, we are able to make a brand new, shorter implementation:
coloration: oklch(from spherical(1.21 - L) 0 0);
In case you’re questioning the place 1.21 got here from, it’s in order that .72 rounds down and .71 rounds up: 1.21 - .72 = .49 rounds down, and 1.21 - .71 = .5 rounds up.
This components works fairly nicely, having put a pair iterations of this components into manufacturing. It’s simpler to learn and preserve. That stated, this components extra intently matches APCA than WCAG, so generally it disagrees with WCAG. For instance, WCAG says black has the next distinction (4.70 than white at 4.3) when positioned on #407ac2, whereas APCA says the other: black has a distinction of 33.9, and white has a distinction of 75.7. The brand new CSS components matches APCA and reveals white:

Arguably, this components could do a greater job than WCAG 2.0 as a result of it extra intently matches APCA. That stated, you’ll nonetheless have to test accessibility, and in the event you’re held legally to WCAG as an alternative of APCA, then possibly this newer less complicated components is much less useful to you.
LCH vs. OKLCH
I did run the numbers for each, and apart from OKLCH being designed to be a greater substitute for LCH, I additionally discovered that the numbers assist that OKLCH is a more sensible choice.
With LCH, the hole between too darkish for black and too mild for white is usually larger, and the hole strikes round extra. For instance, #e862e5 by means of #fd76f9 are too darkish for black and too mild for white. With LCH, that runs between lightness 63 by means of 70; for OKLCH, it’s .7 by means of .77. The scaling of OKLCH lightness simply higher matches APCA.
One Step Additional
Whereas “most-contrast” will definitely be higher, we are able to implement yet another trick. Our present logic merely offers us white or black (which is what the color-contrast() operate is presently restricted to), however we are able to change this to present us white or one other given coloration. So, for instance, white or the bottom textual content coloration. Beginning with this:
coloration: oklch(from spherical(1.21 - L) 0 0);
/* turns into: */
--white-or-black: oklch(from spherical(1.21 - L) 0 0);
coloration: rgb(
from color-mix(in srgb, var(--white-or-black), )
calc(2*r) calc(2*g) calc(2*b)
);
It’s some intelligent math, however it isn’t nice to learn:
- If
--white-or-blackis white,color-mix()leads torgb(127.5, 127.5, 127.5)or brighter; doubled we’re atrgb(255, 255, 255)or increased, which is simply white. - If
--white-or-blackis black,color-mix()cuts the worth of every RGB channel by 50%; doubled we’re again to the unique worth of the.
Sadly, this components doesn’t work in Safari 18 and beneath, so you should goal Chrome, Safari 18+ and Firefox. Nevertheless, it does give us a means with pure CSS to modify between white and a base textual content coloration, as an alternative of white and black alone, and we are able to fallback to white and black in Safari <18.
You may as well rewrite these each utilizing CSS Customized Features, however these aren’t supported all over the place but both:
@operate --white-black(--color) {
consequence: oklch(from var(--color) spherical(1.21 - l) 0 0);
}
@operate --white-or-base(--color, --base) {
consequence: 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 system works nicely for you, and I’d wish to reiterate that the purpose of this method — on the lookout for a threshold and a easy components — is to make the implementation versatile and simple to adapt to your wants. You possibly can simply modify the edge to no matter works finest for you.









