An upcoming personal project faces an interesting UI challenge in which web app users will have the opportunity to change the background color of a page. At the same time, instructions on the page must remain legible, which is impossible if the text remains a fixed color.
The most straightforward solution to this problem is to calculate the perceived “lightness” or “darkness” of the background, and then modify the color of the text so that is opposite that value.
Calculating Luminosity
The equation to calculate the relative luminosity of a color is well known, and fairly straightforward. If each color component (red (r
), green (g
) and blue (b
)) is provided as a value between 0 and 1, and gamma (γ) (the compensation for the non-linear light/dark perception of human vision) is 2.2, then:
luminosity = 0.2126 * rγ + 0.7152 * gγ + 0.0722 * bγ
The result of this calculation is a floating point value between 0 and 1, representing the relative luminosity of any color.
Getting The Background Color
Our first challenge is getting the background color of the page. You might expect the following to work:
var bgColor = document.body.style.backgroundColor;
However, the result will most likely be an empty string. document.body.style.backgroundColor
will only get the inline style of the element. Instead, we need the background color of the body after all styles have been resolved. That’s getComputedStyle
:
var computedStyle = getComputedStyle(document.body, null);
var bgColor = computedStyle.backgroundColor;
Handily, in modern browsers this provides us with the background color in rgb
format:
rgb(10,0,255)
This color format will be returned no matter how the original style might have been applied, or even if it wasn’t defined at all. However, we need each individual red, green and blue component, not the entire rgb
string. To tease out those values, I’ll create a function:
function splitComponent(color) {
var rgbColors=new Object();
color = color.substring(color.indexOf('(')+1, color.indexOf(')'));
var result = color.split(',', 3);
return result ? {
r: parseFloat(result[0]/255),
g: parseFloat(result[1]/255),
b: parseFloat(result[2]/255)
} : null;
}
Very simply, the function strips away the rgb
prefix and parentheses, splits the numbers by looking at the commas between them, then takes each color component and divides it by 255, to yield a floating point value between 0 and 1.
Setting Contrast
Returning to the main code, I’ll call on this function to judge the luminosity of the background:
var luminosity =
0.2126 * Math.pow(splitComponent(bgColor).r,gamma) +
0.7152 * Math.pow(splitComponent(bgColor).g,gamma) +
0.0722 * Math.pow(splitComponent(bgColor).b, gamma);
Once I have that, I can make a judgment on how to color the text above it:
if (luminosity < 0.5) {
document.body.style.color = "#fff"
} else {
document.body.style.color = "#000"
}
All Together Now
The complete code for the example above, starting with the HTML:
<div id="ledge">
<h1>I SHALL ALWAYS REMAIN LEDGIBLE</h1>
<label for="bgcolor">Set the background color for this element:</label>
<input type="color" value="#777777" id="ledgebg" name="ledgebg" oninput="changeBG(ledgebg.value)">
</div>
Initial CSS:
#ledge {
background-color: #332;
color: #fff;
font-family: Avenir, sans-serif;
padding: 2rem;
text-align: center;
}
And JavaScript:
function splitComponent(color) {
var rgbColors=new Object();
color = color.substring(color.indexOf('(')+1, color.indexOf(')'));
var result = color.split(',', 3);
return result ? {
r: parseFloat(result[0]/255),
g: parseFloat(result[1]/255),
b: parseFloat(result[2]/255)
} : null;
}
var gamma = 2.2,
ledge = document.getElementById("ledge");
function changeBG(colorValue){
ledge.style.backgroundColor = colorValue;
var computedStyle = getComputedStyle(ledge, null);
var bgColor = computedStyle.backgroundColor;
var luminosity =
0.2126 * Math.pow(splitComponent(bgColor).r,gamma) +
0.7152 * Math.pow(splitComponent(bgColor).g,gamma) +
0.0722 * Math.pow(splitComponent(bgColor).b, gamma);
if (luminosity < 0.5) {
ledge.style.color = "#fff"
} else {
ledge.style.color = "#000"
}
}
Interestingly, the color
input sets the backgroundColor
of an element in hex. Rather than trying to convert that value into red, green and blue components and then into floating point values, I’ve chosen to read the getComputedStyle
directly after changing the background color.
Alternative Applications
It might be useful to build similar functionality into a Sass mixin, if only as a method of preventing “designer creep” (i.e. the tendency for designers to choose ever-lighter low-contrast colors with thinner, finer typefaces): check out the work by Mike and Ana that extends this idea.
An alternative approach would be to convert the background color into a HSL value, adding 180 to the hue component to swing the text around on the color wheel to the opposite, so-called “complementary” color. However, the combination of complementary colors is not always high contrast or aesthetically pleasing; I would tend to stick with black and white.
Obviously this technique only works with flat background colors and text, not background images; advanced techniques will need to use <canvas>
or CSS overlay
blend mode for text.
Enjoy this piece? I invite you to follow me at twitter.com/dudleystorey to learn more.