The Expanding Gamut of Color on the Web

Avatar of Ollie Williams
Ollie Williams on (Updated on )

CSS was introduced to the web all the way back in 1996. At the time, most computer monitors were pretty terrible. The colors of CSS — whether defined with the RGB, HSL, or hexadecimal format — catered to the monitors of the time, all within the sRGB colorspace.

Most newer devices have a wide-gamut display. A gamut is the range of colors that can be displayed. A wide-gamut display is capable of showing more colors than sRGB. They use the Display P3 colorspace. (There’s also Rec.2020, an even larger colorspace, but that’s pretty rare and not currently worth thinking about.) As Lea Verou of the CSS working group put it, “Our websites are washed out because screens advanced faster than CSS Color did.” If we want to make full use of the range of colors that the majority of screens are capable of displaying, we need to use new CSS colors formats: lab, lch or display-p3.

Examples in the wild can be found on the website of Panic (creators of the once popular Coda text editor and the still very popular Untitled Goose Game) or the marketing site for a product called Playdate. They both make use of strikingly vibrant and intense colors that are uniquely vivid by making use of display-p3.

Screenshot taken from the Panic website showing bright pink text against a stark black background.
Panic’s website features an eye-catching shade of pink.

To get some idea of the range of colors that are missing from sRGB, check out the following Pen. The inner boxes contain a color beyond the sRGB gamut. The outer boxes show that color clamped to the sRGB color gamut (meaning the nearest equivalent color that a browser is capable of showing without using display-p3, lab, or lch). (Note that support is currently limited to Safari users.)

The color picker in Safari Technology Preview helpfully shows which colors lie outside of the sRGB color gamut.

Screenshot of a color picker going from bright green to black with a light curved line signifying the point where colors go past the typical sRGB range.
Any color above or to the right of the white line lie outside of the sRGB gamut

A tale of new syntaxes

Before jumping into the syntax for lab(), lch(), and the color() function, let’s take a look at the new rgb() and hsl() syntaxes (which are supported in all web browsers, minus Internet Explorer).

TypeOld SyntaxNew Syntax
RGBrgb(0, 128, 255)rgb(0 128 255)
RGBargba(0, 128, 255, 0.5)rgb(0 128 255 50%)
HSLhsl(198, 28%, 50%)hsl(198 28% 50%)
HSLahsla(198, 28%, 0.5)hsl(198deg 28% 50% / 50%)
Source: @mathias 

In the older syntax, each number is comma separated: rgb(200, 100, 20);. Commas are no longer necessary, so the space separated value rgb(200 100 20); is valid. To specify transparency, we can now use rgb(200 100 20 / 50%) rather than using  rgba() or hsla(). There’s no real benefit to the newer syntaxes but it’s worth looking at because they match the syntax for lch(), lab() and color()

TypeSyntax
Lablab(56.29% -10.93 16.58 / 50%)
color()color(sRGB 0 0.5 1 / 50%)
LCHlch(56.29% 19.86 236.62 / 50%

lab(), lch() and color() always use space separated numbers (no commas allowed) and a forward slash followed by a percentage to specify transparency. Let’s take a look at how they work.  

The CSS color() function and display-p3 colorspace

The color() function allows a color to be specified in a particular colorspace (rather than using the sRGB colorspace used by rgb(), hsl(), or hex). The colorspace we need to specify in order to use wide-gamut color is display-p3, which uses three numeric values, representing the red, green, and blue channels of the color: 1 0 0 is total red, 0 0 1 is total blue, and 0 1 0 is total green.

background-color: color(display-p3 1 0 0.331); /* vibrant pink color */

At the time of writing, display-p3 is the only way to access high-gamut colors, having been supported in Safari since 2017. However, lab() and lch() will be better options once they are implemented (Chrome and Safari are currently working on it). Here’s a take from Lea Verou

display-p3 is not perceptually uniform, and is difficult to create variants (lighter or darker, more or less vivid etc) by tweaking its parameters. Furthermore, it’s a short-term solution. It works now, because screens that can display a wider gamut than P3 are rare. Once hardware advances again, color(display-p3 ...) will have the same problem as sRGB colors have today. LCH and Lab are device independent, and can represent the entire gamut of human vision so they will work regardless of how hardware advances.

A better lightness: Lab and LCH

You may have seen articles around the web arguing that HSL is easier to reason about than RGB or Hexadecimal values. 

Here’s Chris Coyier in 2015:

The real appeal of HSLa is that it makes more intuitive sense what changing the values will do to the color. Increasing the second value will increase the saturation of that color. Decreasing the third value will decrease the lightness of that color. That makes creating your own color variations on the fly way easier.

While HSL might be easier to understand than hexadecimal or RGB, it’s far from perfect. The way it calculates lightness simply doesn’t match human perception. According to HSL, hsl(240deg 100% 50%) and hsl(60deg 100% 50%) have the same lightness, 50%. Let’s compare the two.

To the human eye, the blue looks darker. As Brian Kardell puts it: 

Doing things like mixing colors, lightening, darkening, can be done well only if they include a sense of how our eyes really work rather than how machines like to think about storing and displaying.

Here’s a visual example from Lea Verou that demonstrates the superiority of Lab/LCH over HSL. She comments

A trick for aesthetically pleasing gradients of the same color at different lightnesses is to convert to Lab, vary the L instead, and then convert back to HSL/RGB.

“The perceived brightness of all of the hues in a spectrum with the same saturation and lightness. […] It’s quite clear they’re different.” —Brian Kardell (Image: Rob Waychert)

Lab and LCH both use the CIELAB colorspace which is designed to align with human vision. If you give two colors the same lightness value, they appear to the human eye to have the same lightness, regardless of their hue.

Lab

background-color: lab(40% 83 -104); /* a shade of purple */

The L in lab() stands for lightness and is written as a percentage (which can go up to 400% for extra bright white, but will generally be between 0% and 100% ). A and B don’t stand for anything — they’re color channels. A is a numerical value between green (negative values) and red (positive values) while B is a numerical value between blue (negative values) and yellow (positive values). Lightness is pretty easy for us to understand. The red/green value and blue/yellow value, however, aren’t exactly intuitive. LCH is probably a better alternative.

LCH

background-color: lch(69% 56 244); /* a shade of blue */

lch() is the most human-readable of the new color values. L again stand for lightness (and works in exactly the same way), C is for chroma, and H is for hue. Chroma is largely analogous to saturation, but it can also be thought of as the color intensity or vibrancy. Unlike the other new color formats, you can actually predict the sort of effect changing these individual values will have — its similar to HSL in this way. The best way to get your head around it is to try out this LCH color picker.

Defining fallbacks

We have two kinds of support to think about: browser support for the new CSS color values and the ability of screens to display these colors.

Falling back to the closest matching sRGB value for browsers that don’t support color functions is easy and exactly like we’re used to defining fallback properties:

.pink-text {
  color: rgb(255, 0, 79); /* Will be used as a fallback */
  color: color(display-p3 1 0 0.331); /* Will be used if supported */
}

The second line of code in the example above will be ignored if the browser doesn’t understand it and the rgb() value will be used instead, thanks to the cascade. It would be laborious to type out two lines of CSS every time you want to specify a color. CSS variables are a great way to deal with this. In this example we’ll use @supports to tell if the browser has support for color functions in CSS:

/* https://webkit.org/blog/10042/wide-gamut-color-in-css-with-display-p3/ */
:root {
  --bright-green: rgb(0, 255, 0);
}


/* Display-P3 color, when supported. */
@supports (color: color(display-p3 1 1 1)) {
  :root {
    --bright-green: color(display-p3 0 1 0);
  }
}


header {
  color: var(--bright-green);
}

If the color is particularly important to your design, you could utilize a background-image as most browsers do support high-gamut colors in images.

@supports not (color: color(display-p3 1 0 0.331)) {
  @supports (-webkit-background-clip: text){
    .pink-text {
      background-image: url("pink-P3.png");
      background-size: cover;
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
  }
}


.pink-text {
  color: rgb(255, 0, 79);
  color: color(display-p3 1 0 0.331);
}

There is a PostCSS plugin that converts lab() and lch() functions to rgb(). If you’re into Sass there is a tool from Miriam Suzanne called Blend.

A media query for color

@supports tells us whether the browser supports the relevant CSS syntax. What it doesn’t tell us  is whether a user’s monitor can actually display certain color values. If a monitor doesn’t support high-gamut color, the screen will display the nearest equivalent sRGB color. This means all monitors are catered for without writing any extra code.

However, if you’d rather choose the fallback color manually yourself rather than let the browser calculate one for you, you can pass a second color value to the color()  function. This would, however, require browser support for the color function (but support for the second argument hasn’t landed in any browser yet).

background-color: color(display-p3 1 0 0.331, #f54281);

Should you need greater control to do something fancy, the Media Queries Level 4 spec brings a new color-gamut media query that can help us here.

@media (color-gamut: p3) { 
  /* Code to run only on hardware that supports P3 color */
}

In this example, we are obviously checking for P3 support, but we could also check for the rec-2020 colorspace we alluded to earlier, which has an even wider gamut than P3. The number of screens supporting rec-2020 is currently minimal and only include high-definition televisions, meaning they won’t be a common target for developers in the near future. You can also check for sRGB support, but that is almost all monitors nowadays. The color-gamut query, on the other hand, has reasonably good browser support at the time of writing.

Sidenote: dynamic-range media query

In the Safari 13.1 release notes, the dynamic-range media query is is used to conditionally apply a P3 color.  Apparently, that’s not a good use case. According to Florian Rivoal (editor of the Media Queries specification), this query is designed to be used for video:

[S]ome screen can show ultra-bright lights for brief amounts of times, that are used in videos for things like sparks, direct sunlight, etc. This is much brighter than white, and isn’t meant to be used with static images. It would be uncomfortable, and would also damage the screen.

One more sidenote: Design tool support

Unfortunately popular web design tools like Figma, Sketch and XD do not currently support Lab, LCH or P3 colorspaces. Photoshop, however, does have a Lab color picker.


There we have it! CSS colors are expanding at a time when screens support more colors than ever. It’s an exciting time for color nerds out there!