As you may know, the recent updates and additions to CSS are extremely powerful. From Flexbox to Grid, and — what we’re concerned about here — Custom Properties (aka CSS variables), all of which make robust and dynamic layouts and interfaces easier than ever while opening up many other possibilities we used to only dream of.
The other day, I was thinking that there must be a way to use Custom Properties to color an element’s background while maintaining a contrast with the foreground color that is high enough (using either white or black) to pass WCAG AA accessibility standards.
It’s astonishingly efficient to do this in JavaScript with a few lines of code:
var rgb = [255, 0, 0];
function setForegroundColor() {
var sum = Math.round(((parseInt(rgb[0]) * 299) + (parseInt(rgb[1]) * 587) + (parseInt(rgb[2]) * 114)) / 1000);
return (sum > 128) ? 'black' : 'white';
}
This takes the red, green and blue (RGB) values of an element’s background color, multiplies them by some special numbers (299, 587, and 144, respectively), adds them together, then divides the total by 1,000. When that sum is greater than 128, it will return black; otherwise, we’ll get white. Not too bad.
The only problem is, when it comes to recreating this in CSS, we don’t have access to a native if
statement to evaluate the sum. So,how can we replicate this in CSS without one?
Luckily, like HTML, CSS can be very forgiving. If we pass a value greater than 255 into the RGB function, it will get capped at 255. Same goes for numbers lower than 0. Even negative integers will get capped at 0. So, instead of testing whether our sum is greater or less than 128, we subtract 128 from our sum, giving us either a positive or negative integer. Then, if we multiply it by a large negative value (e.g. -1,000), we end up with either very large positive or negative values that we can then pass into the RGB function. Like I said earlier, this will get capped to the browser’s desired values.
Here is an example using CSS variables:
:root {
--red: 28;
--green: 150;
--blue: 130;
--accessible-color: calc(
(
(
(
(var(--red) * 299) +
(var(--green) * 587) +
(var(--blue) * 114)
) / 1000
) - 128
) * -1000
);
}
.button {
color:
rgb(
var(--accessible-color),
var(--accessible-color),
var(--accessible-color)
);
background-color:
rgb(
var(--red),
var(--green),
var(--blue)
);
}
If my math is correct (and it’s very possible that it’s not) we get a total of 16,758, which is much greater than 255. Pass this total into the rgb()
function for all three values, and the browser will set the text color to white.
Throw in a few range sliders to adjust the color
values, and there you have it: a dynamic UI element that can swap text color based on its background-color
while maintaining a passing grade with WCAG AA.
See the Pen
CSS Only Accessible Button by Josh Bader (@joshbader)
on CodePen.
Putting this concept to practical use
Below is a Pen showing how this technique can be used to theme a user interface. I have duplicated and moved the --accessible-color
variable into the specific CSS rules that require it, and to help ensure backgrounds remain accessible based on their foregrounds, I have multiplied the --accessible-color
variable by -1 in several places. The colors can be changed by using the controls located at the bottom-right. Click the cog/gear icon to access them.
See the Pen
CSS Variable Accessible UI by Josh Bader (@joshbader)
on CodePen.
There are other ways to do this
A little while back, Facundo Corradini explained how to do something very similar in this post. He uses a slightly different calculation in combination with the hsl
function. He also goes into detail about some of the issues he was having while coming up with the concept:
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?
He goes on to mention that Edge wasn’t capping his large numbers, and during my testing, I noticed that sometimes it was working and other times it was not. If anyone can pinpoint why this might be, feel free to share in the comments.
Further, Ana Tudor explains how using filter
+ mix-blend-mode
can help contrast text against more complex backgrounds. And, when I say complex, I mean complex. She even goes so far as to demonstrate how text color can change as pieces of the background color change — pretty awesome!
Also, Robin Rendle explains how to use mix-blend-mode
along with pseudo elements to automatically reverse text colors based on their background-color
.
So, count this as yet another approach to throw into the mix. It’s incredibly awesome that Custom Properties open up these sorts of possibilities for us while allowing us to solve the same problem in a variety of ways.
Pretty clever
This is awesome! I love the pen you made about “Putting this concept to practical use”.
Thanks for the article! Where did you get the “special numbers” from?
The W3C has them listed here… https://www.w3.org/TR/AERT/#color-contrast
I have no idea why but it doesn’t work on Gecko 56
The problems with capping numbers may be related to the OS and hardware setup. The way color functions are implemented can easily be dependent on whether the browser has access to hardware accelleration or not. In the past, I have observed similar errors for the “gooey effect” (in this case, the alpha value), although this seems to be resolved by now.
Josh, this is a cool idea but I’m afraid your math is not accurate—neither in the JavaScript nor the CSS. The “special numbers” you refer to are poorly explained on that w3c page. In fact, the red, blue and green need to be first normalized (set in a range from 0 to 1) and then linearized (converted from sRGB to linearRGB) before you pass them in to that formula. The latter step is missed in many implementations, including for example Bootstrap’s
yiq()
Sass function, but it is absolutely the only correct calculation and there are w3c and WCAG documents that do correctly reference it. Unfortunately it requires apow()
function, which JavaScript has but CSS does not, so an accurate emulation of this usingcalc()
won’t work.This w3c document correctly shows that the RGB values must be linearized. The previous document you linked mentions “This algorithm is taken from a formula for converting RGB values to YIQ values” without explaining that YIQ color assumes the gamma is already linear.
And according to caniuse.com, –var() still not an option for IE11, which I’m discovering by angry backlash from various clients, still in use…
Great idea! How could we translate this to SCSS? This would get around the IE11 compatibility issue.
I actually later found Bootstrap’s YIQ function at https://getbootstrap.com/docs/4.3/getting-started/theming/#color-contrast. I guess it’s the same sort of thing?
It’s the same idea, but unfortunately mathematically incorrect ♂️
Lu that was my concern. Would be great if we could override the function to make it work properly.
@Glenn
This pen contains the correct math, also a copy of Bootstrap’s yiq() for comparison. Watch out though, this code depends on a Sass
pow()
function. I’m using mathsass to provide one, via a Pen dependency (see the settings). This library is good but runs in SassScript so it’s slow. Ideal would be to plug in some JavaScript math functions using Sass’functions
Node API. I have a simple set of these that I could put in a Gist if you’re interested.Wow thanks for that Lu. I’m definitely interested. I think whatever the solution is it’ll need to be easy to plug in to a new project otherwise my colleagues won’t use it – they’re not as interested in this stuff as I am.
Really great job!
Unfortunately, MS Edge don’t like calc() with css variables for now :/
Have to wait to use it in production.
Great job, except for one thing: your color picker sliders don’t have accessible colored text to indicate the slider’s value! So if you make the quaternary color (which is used for the background of the color picker) very light, you won’t be able to see the actual numeric values of the sliders. I’m trying this in Chrome, so it’s possible other browsers do things differently; but you might want to consider putting a dark backdrop behind the sliders if you can’t control the color of their indicator text.
You wouldn’t have to keep repeating the big formula if you apply it on * instead of on :root, using generic –red, –green, –blue variables. Then anywhere you want to use it, set the generic red, green, blue variables for that selector.
Here is a fork of the codepen: https://codepen.io/joyously/pen/QWMdXXB