Today I Learned

Contrast-Color & Alternatives

Recently I was building a style guide webpage when I reached the 'colours' section and began listing out the palette colours with some slick boxes containing the hex code of the colour sat on top of it, like so:

#003049
#D62828
#F77F00
#FCBF49
#EAE2B7

Can you see the problem straight away? The text is only legible on some of the boxes - unacceptable!

Of course we could just add a class to each of the darker boxes with a different text colour so that we can ensure there's plenty of contrast, but that's far too manual and outdated... so what other solutions are there?

Can CSS's contrast-color() help?

Absolutely! CSS's contrast-color() is a succinct and clear solution to this problem. It works by taking a colour and returning either white or black, depending on which will have better contrast, and will always try to meet WCAG AA minimum contrast.

It's pretty easy to set up too, requiring just 2 lines of CSS code and a style variable per box:

.box {
  background-color: var(--box-colour);
  color: contrast-color(var(--box-colour));
}
<div style="--box-colour:#003049;">#003049</div>
<div style="--box-colour:#D62828;">#D62828</div>
...
#003049
#D62828
#F77F00
#FCBF49
#EAE2B7

Perfect!! Sadly, at the time of implementing my style guide contrast-color() was only newly available, and I couldn't guarantee it'd work for all my users and their potentially-outdated browsers.

Are there alternatives to contrast-color()?

Enter the brilliant, pseudo-hacky solution from Jake Lazaroff that utilises CSS's lch() colour function:

div {
  background-color: var(--box-colour);
  color: lch(from var(--box-colour) calc((49.44 - l) * infinity) 0 0);
}
#003049
#D62828
#F77F00
#FCBF49
#EAE2B7

It's does the same job as contrast-color(), but it's much better supported - lch() became baseline in 2023. Let's break it down:

  • lch stands for light, chroma, hue.

  • Usually the part at the start of the function, from var(--box-colour), would tell the function to get the l (light), c (chroma) and h (hue) from that variable.

  • But in our equation we're only interested in getting the l from the variable and hardcoding the c and h as '0', because those are irrelevant (we just want black or white, remember).

  • The magic happens in the l calc: calc((49.44 - l) * infinity) - we're essentially trying to get an answer that is either infinity or negative infinity. Why? Because the number is clamped by lch within between 0 & 100, so infinity stops at 100 (white) and -infinity stops at 0 (black) in this context.

  • Basically, we're inverting the original box colour's lightness and multiplying it by infinity, and that gives us black or white for the l. Smart stuff!

  • Why 49.44? The original creator (Devon Govett) throughly tested the equation with different RGB colours and this was the most accurate and stable integar to use for inverting the colour's lightness to ensure good contrast.

Example 1: #003049

  1. #003049 has a lightness of 18.021.
  2. 49.44 - 18.021 = 31.419
  3. 31.419 * infinity = infinity (positive)
  4. infinity gets clamped at 100 (white)

Result:

#003049

Example 2: #FCBF49

  1. #FCBF49 has a lightness of 81.517.
  2. 49.44 - 81.517 = -32.077
  3. -32.077 * infinity = -infinity (negative)
  4. -infinity gets clamped at 0 (black)

Result:

#FCBF49

Once you break it down, it makes a lot of sense! We just want infinity or -infinity, based on the background colour, which gives us black or white text... it's genius yet so simple!


Official docs: MDN contrast-color function, MDN lch function


Back to TIL

Let's grow your business,
together

Whether you're after dev work on a brand new site, an updated design, or just have a great business idea you want to get the ball rolling on, I'm here to help. Let's collaborate together!

Start a project