Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

article on color contrast #76

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 289 additions & 0 deletions source/advanced/color-contrast.html.markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
---
date: 2016-03-05 21:59:03 +0100
categories: advanced
author: Tobias Bengfort
summary: Color contrast is essential for legibility. But calculating it correctly is not as simple as it may seem, especially if you consider transparent colors.
---

# Color contrast in web design

Color contrast is essential for legibility in web design. The web content
accessibility guidelines (WCAG) therefore include [strict requirements on
minimum contrast](https://www.w3.org/TR/WCAG20/#visual-audio-contrast).

In this article I want to explain some details about color contrast and propose
a new algorithm that supports transparency and can be implemented in existing
libraries without losing backwards compatibility. I will also discuss the
current state of implementation in CSS preprocessors such as
[Less](http://lesscss.org/) or [Sass](http://sass-lang.com/). Out of these, I
personally prefer Sass, so all examples are written in that language.

One final note: I assume that you have a basic understanding of working with
colors, especially in CSS.

## Solid colors

### Definition

So what is color contrast? There are [many
definitions](https://en.wikipedia.org/wiki/Color_contrast), but the W3C chose
one. As a first step, they decided that contrast should not factor in hue or
saturation in order to make it meaningful for people with different kinds of
color deficiencies. So their contrast formula is purely based on *luma*.

So what is luma? More precisely: What is the difference between luma and the
related concepts *brightness* and *luminance*?

Brightness is simply the average of the red, green and blue channels. Luma and
luminance on the other hand factor in the fact that humans perceive some colors
as brighter than others. Compare for example yellow and blue. This can be done
by using the [following
formula](https://www.w3.org/TR/WCAG20/#relativeluminancedef):

:::sass
$l = 0.2126 * $r + 0.7152 * $g + 0.0722 * $b

This formula, as well as everything that follows, assumes that the RGBA values
are in the range from 0 to 1.

The difference between luminance and luma is that the former is based on raw
RGB values; the latter factors in [*gamma
correction*](https://en.wikipedia.org/wiki/Gamma_correction).

Humans are better at distinguishing dark colors than bright colors. Gamma
correction is a method that takes advantage of that. The gamma correction
usually used on the web is taken from the
[sRGB](https://en.wikipedia.org/wiki/Srgb) standard and can be calculated like
this:

:::sass
@function srgb($channel) {
@if $channel <= 0.03928 {
@return $channel / 12.92;
} @else {
@return pow(($channel + 0.055) / 1.055, 2.4);
}
}

Putting both formulas together we get this code for calculating the luma:

:::sass
@function luma($color) {
$r: srgb(red($color) / 255);
$g: srgb(green($color) / 255);
$b: srgb(blue($color) / 255);
@return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
}

Back to contrast: It is now simply defined as a [ratio of
lumas](https://www.w3.org/TR/WCAG20/#contrast-ratiodef):

:::sass
@function contrast($color1, $color2) {
$l1 = luma($color1);
$l2 = luma($color2);
@return (max($l1, $l2) + 0.05) / (min($l1, $l2) + 0.05);
}

Note that the contrast can have values between 1 and 21. I am not sure what the
rationale for the 0.05 is. It prevents the formula from going to infinity
for near-black colors, but I find that it still produces overly high results
for those.

### Implementations

I have seen three types of functions that you may wish to have in your CSS
preprocessing code:

- A function that calculates the contrast
- A function that warns you if the contrast is below a certain threshold
- A function that picks the color with the best contrast to a base color out
of a list of alternatives

Less contains a [function to calculate
luma](http://lesscss.org/functions/#color-channel-luma). It also contains a
function of the third type called
[`contrast()`](http://lesscss.org/functions/#color-operations-contrast). This
function is not based on the W3C definition of contrast, but there is a [pull
request](https://github.com/less/less.js/pull/2754) to fix that.

Sass, on the other hand, does not contain any of these functions. It even lacks
a `pow()` function needed to calculate gamma correction. However, compass (a
popular Sass library) has a function of the third type called
[`contrast-color()`](http://compass-style.org/reference/compass/utilities/color/contrast/)
that is not based on the W3C definition. There is also a more specialised
library called [sass-a11y](https://github.com/at-import/sass-a11y) which
contains a function of the second type called `a11y-contrast()`. This one seems
to be correct.

## Transparent colors

So now that we know how to calculate color contrast of solid colors, let's turn
to transparent colors. This topic has been raised by [Lea
Verou](http://lea.verou.me/2012/10/easy-color-contrast-ratios/) in 2012. The
standard does not mention transparent colors, but in theory they work a lot
like solid colors.

The one additional step you have to do is [*alpha
blending*](https://en.wikipedia.org/wiki/RGBA), i.e. combine the transparent
color with its background color to get the combination:

:::sass
@function alpha-blend($fg, $bg) {
$a: alpha($fg);

$r: red($fg) * $a + red($bg) * (1 - $a);
$g: green($fg) * $a + green($bg) * (1 - $a);
$b: blue($fg) * $a + blue($bg) * (1 - $a);

@return rgb($r, $g, $b);
}

So we can simply apply alpha blending, then calculate the contrast and we are
done, right? Unfortunately, there are two major issues with this:

- If the background color is transparent itself we need to know (or guess) a
third color (called backdrop) that it will be blended on.
- In order to apply alpha blending, we need to know which color is in the
foreground and which one is in the background. This did not make any
difference before.

Lea got around these issues because she wrote a new library from scratch. Her
function takes foreground and background colors in a specific order and
returns two values: A minimum and a maximum possible contrast resulting from
different backdrops.

I wanted to come up with an algorithm that could easily be implemented in
existing libraries, so breaking the API was undesirable. So the final sections
of this article will describe the approach I took.

### Transparent backgrounds

Let's turn to the issue of the unknown backdrop color first. These are some
approaches I could think of:

- **Ignore the transparency.** The result of the algorithm should be that
colors with low alpha channel are considered to have a low contrast to
almost everything. So ignoring the transparency completely does not sound
like the right thing to do, though it would save us a lot of headaches.

- **Use white as a backdrop color as it is the default background color on
most websites.** Using a white background might seem like a good idea, but
using a transparent background on a solid backdrop color is not actually a
common use case and should be advised against (just use the blended color
directly in these cases). [Almost all
examples](http://tympanus.net/codrops/2012/11/26/using-transparency-in-web-design-dos-and-donts/)
I saw used transparent backgrounds to increase contrast when displaying
text over images. So there is really no basis for assuming a white
backdrop.

- **Calculate the minimum or maximum possible contrast or some combination of
them.** The difficulty with this approach is that using minimum or maximum
seems to be a bit biased against or in favour of transparency. And there
are so many possible ways to combine them that it is not clear which one to
choose.

- **Calculate the expected value.** This might be the proper approach from a
purely theoretical perspective. It basically means that we choose some
random backdrop colors, calculate the contrast, and use the average of the
results. The big issue with this approach is that we would need to know
how probable each color is as a backdrop. How would we define that? Do a
field study or just assume that all colors are equally probable? This
sounds like overkill.

Taking into account that the backdrop is most likely not a single color but an
image, I think the most sensible approach out of these is to use the minimum
possible contrast. So how is it calculated?

The luma is strictly increasing in relation to every color channel. So the
minimum luma can be achieved by using black as a backdrop color, while the
maximum can be achieved with white. The luma is also continuous, meaning that
for every possible luma between minimum and maximum there is a backdrop color
that can produce it.

This means that the minimum contrast is 1 if the foreground luma is somewhere
in that range. Otherwise, it is the minimum of the white/black cases:

:::sass
@function contrast-min($fg, $bg) {
$bg-black: alpha-blend($bg, black);
$bg-white: alpha-blend($bg, white);

@if luma($bg-white) < luma($fg) {
@return contrast($fg, $bg-white);
} @else if luma($bg-black) > luma($fg) {
@return contrast($fg, $bg-black);
} @else {
@return 1;
}
}

### Background/foreground

In the case of solid colors, it was not relevant which of the colors was
background and which was foreground. Now it is: When a transparent foreground
is overlayed on the background, it is mixed with it, resulting in a slightly
decreased contrast. A transparent background can have much more extreme effect
depending on the backdrop. For example, the background may have a lower luma
than the foreground, but when overlayed on white the result has a higher luma.

This effect is unfortunate, because it makes the API much more complicated and
is largely incompatible with existing implementations. So are there any ways
around it?

Let's first look at the actual impact. In order to do that we swap foreground
and background colors and compare the results of different algorithms.

In the case of minimum contrast, the regular and the swapped functions are
correlated (I calculated a correlation of 0.88 in a set of 10000 random
colors). This makes sense because the contrast goes down for both transparent
foreground and transparent background.

In the case of maximum contrast, they are negatively correlated (-0.23). This
makes sense because the contrast goes down for transparent foreground while it
goes up for transparent background.

Given the high cost this would have and the high correlation between minimum
contrast and its swapped version, I think it is a sensible approach to use a
"symmetric minimal contrast" that is the average of the two:

:::sass
@function contrast-min-symmetric($color1, $color2) {
$c1 = contrast-min($color1, $color2);
$c2 = contrast-min($color2, $color1);
@return ($c1 + $c2) / 2;
}

### Implementations

- Lea Verou created a [tool](https://leaverou.github.io/contrast-ratio/)
written in JavaScript that reports minimum and maximum possible contrasts
for transparent background colors. It requires you to know which of the
colors will be the fore-/background.
- Less ignores the alpha channel completely in its `contrast()` function and
they are [not going to change
that](https://github.com/less/less.js/pull/2843).
- Compass also ignores the alpha channel in its `contrast-color()` function.
- sass-a11y implements the algorithm proposed by Lea.
- I created a Sass library called
[sass-planifolia](https://github.com/xi/sass-planifolia) which implements
the symmetric minimal contrast algorithm.

## Conclusion

This article covered many details about color contrast as well as a new
algorithm that supports transparency and can be implemented in existing
libraries without losing backwards compatibility.

We discussed ways to ensure a minimum color contrast. Note, however, that this
may not be sufficient to ensure good legibility: Typography and font size are
other key factors. Also note that too much contrast can be hard on the eyes,
especially very bright colors on dark background.

This whole topic turned out to be surprisingly complicated, touching topics
such as psycho-visual effects, maintaining backwards compatibility, and a
significant amount of math.

While writing this I created several bug reports and pull requests.
Unfortunately, some projects mentioned are no longer maintained, e.g. Compass
and sass-a11y.