Sunday, 12 August, 2018 UTC

Summary

Ever get one of those, "I can do that with CSS!" moments while watching someone flex their JavaScript muscles? That’s exactly the feeling I got while watching Dag-Inge Aas & Ida Aalen talk at CSSconf EU 2018.

They are based in Norway, where WCAG accessibility is not a just good practice, but actually required by law (go, Norway!). As they were developing a feature that allows user-selectable color theming for their main product, they faced a challenge: automatically adjusting the font color based on the selected background color of the container. If the background is dark, then it would be ideal to have a white text to keep it WCAG contrast compliant. But what happens if a light background color is selected instead? The text is both illegible and fails accessibility.

They used an elegant approach and solved the issue using the "color" npm package, adding conditional borders and automatic secondary color generation while they were at it.

But that’s a JavaScript solution. Here’s my pure CSS alternative.

The challenge

Here is the criteria I set out to accomplish:

Change the font color to either black or white depending on the background color

Apply the same sort of logic to borders, using a darker variation of the base color of the background to improve button visibility, only if background is really light

Automatically generate a secondary, 60º hue-rotated color

Working with HSL colors and CSS variables

The easiest approach I could think for this implies running HSL colors. Setting the background declarations as HSL where each parameter is a CSS custom property allows for a really simple way to determine lightness and use it as a base for our conditional statements.

This should allow us to swap the background to any color we’d like at runtime by changing the variables and running an if/else statement to change the foreground color.

Except... we don’t have if/else statements on CSS… or do we?

Introducing CSS conditional statements

Since the introduction of CSS variables, we also got conditional statements to go with them. Or sort of.

This trick is based on the fact that some CSS parameters get capped to a min and max value. For instance, think opacity. The valid range is 0 to 1, so we normally keep it there. But we can also declare an opacity of 2, 3, or 1000, and it will be capped to 1 and interpreted as such. In a similar fashion, we can even declare a negative opacity value, and get it capped to 0.

Applying the trick to our font color declaration

The lightness parameter of an HSL color declaration behaves in a similar way, capping any negative value to 0 (which results in black, whatever the hue and saturation happens to be) and anything above 100% is capped to 100% (which is always white).

So, we can declare the color as HSL, subtract the desired threshold from the lightness parameter, then multiply by 100% to force it to overshoot one of the limits (either sub-zero or higher than 100%). Since we need negative results to resolve in white and positive results to resolve in black, we also have to invert it multiplying the result by -1.

Let’s review that bit of code: starting from a lightness of 80 and considering a 60 threshold, the subtraction results in 20, which multiplied by -100%, results in -2000% capped to 0%. Our background is lighter than the threshold, so we consider it light and apply black text.

If we had set the --light variable as 20, the subtraction would have resulted in -40, which multiplied by -100% would turn 4000%, capped to 100%. Our light variable is lower than the threshold, therefore we consider it a "dark" background and apply white text to keep a high contrast.

Generating a conditional border

When the background of an element becomes too light, it can get easily lost against a white background. We might have a button and not even notice it. To provide a better UI on really light colors, we can set a border based on the very same background color, only darker.

A light background with a dark border based on that background color.

To achieve that, we can use the same technique, but apply it to the alpha channel of a HSLA declaration. That way, we can adjust the color as needed, then have either fully transparent or fully opaque.

Assuming a hue of 0 and saturation at 100%, the above code will provide a fully opaque, pure red border at 70% the original lightness if the background lightness is higher than 80, or a fully transparent border (and therefore, no border at all) if it’s darker.

Setting the secondary, 60º hue-rotated color

Probably the simplest of the challenges. There are two possible approaches for it:

filter: hue-rotate(60): This is the first that comes to mind, but it’s not the best solution, as it would affect the color of the child elements. If necessary, it can be reversed with an opposite rotation.

HSL hue + 60: The other option is getting our hue variable and adding 60 to it. Since the hue parameter doesn’t have that capping behavior at 360 but instead wraps around (as any CSS <angle> type does), it should work without any issues. Think 400deg=40deg, 480deg=120deg, etc.

Considering this, we can add a modifier class for our secondary-colored elements that adds 60 to the hue value. Since self-modifying variables are not available in CSS (i.e. there’s no such thing as --hue: calc(var(--hue) + 60) ), we can add a new auxiliary variable for our hue manipulation to our base style and use it in the background and border declaration.

Except it doesn’t. Some hues get really problematic (particularly yellows and cyans), as they are displayed way brighter than others (e.g. reds and blues) despite having the same lightness value. In consequence, some colors are treated as dark and given white text despite being extremely bright.

What in the name of CSS is going on?

Introducing perceived lightness

I’m sure many of you might have noticed it way ahead, but for the rest of us, turns out the lightness we perceive is not the same as the HSL lightness. Luckily, we have some methods to weigh in the hue lightness and adapt our code so it responds to hue as well.

To do that, we need to take into consideration the perceived lightness of the three primary colors by giving each a coefficient corresponding to how light or dark the human eye perceives it. This is normally referred to as luma.

There are several methods to achieve this. Some are better than others in specific cases, but not one is 100% perfect. So, I selected the two most popular, which are good enough:

For the conditional borders, we need to turn the declaration into a RGBA, and once again, use the alpha channel to make it either fully transparent or fully opaque. Pretty much the same thing as before, only running RGBA instead of HSLA. The darker shade is obtained by halving each color channel.

This is not the best thing in the world. Besides applying the hue rotation to potential child elements as previously discussed, it means the switch to black/white and border visibility on the secondary element will depend on the main element’s hue and not on its own. But as far as I can see, the JavaScript implementation has the same issue, so I’ll call it close enough.

A pure CSS solution that achieves the same effect as the original JavaScript approach, but significantly cuts on the footprint.

Browser support

IE is excluded due to the use of CSS variables. Edge doesn’t have that capping behavior we used throughout. It sees the declaration, deems it nonsense, and discards it altogether as it would any broken/unknown rule. Every other major browser should work.

The post Switch font color for different backgrounds with CSS appeared first on CSS-Tricks.