hwb() lab() color(srgb) lch() color(display-p3) oklab() color(rec2020) oklch() hsl(none none none / 50%)

What the color?!

by Lea Verou CSS WG, CSS Color co-editor, Color.js co-founder lea.verou.melea@verou.mecolorjs.io
😵‍💫 🥴 🫠
linear-gradient(in oklab) lab() color-mix() lch() color(display-p3) linear-gradient(in oklch longer hue) color(rec2020) oklch()
Raise your hand if you have been confused around the developments in CSS Color in the last few years. Why do we need all of these? What are they good for? Should I care?
(Show 100% and 0%, then increase CSS and try agian, wtf?!) Or maybe you tried to use these new color spaces and were baffled at the result. Why does changing the lightness not seem to work for certain values? Shouldn't 0 be black and 100 be white? Why are we getting these extremely bright colors instead?
But even beyond CSS, this talk will help you figure out answers to longstanding questions such as "why are printed colors so washed out when they looked lovely on my screen"?
Or why does it seem so impossible to recreate certain colors with a color picker on a screen?
Or why was the t-shirt I bought a different color than what it looked like on Amazon?
The opposite happens too. This is what this dress looked like on Amazon. Its real color is way brighter and more vibrant, right? This talk will explain why this was not their fault — bright turquoise is a notoriously hard color to reproduce on a screen — it's not just fluorescent colors.

RGB

color spaces
(and why they suck)

To understand all of this, we first need to go back to the beginning.
First color CRT (1954)
Modern high-end P3 LCD
Red, green and blue is how our computer screens produce color. Imagine three lightbulbs. One red, one green, and one blue, each having a dimmer. This is basically how pixels are made. And this mechanism has remained largely unchanged since the first color screens in the 50s. We just got more levels for these dimmers, and their max brightness got higher. But ultimately, every pixel on your screen is still three teeny tiny dimmable lightbulbs.
Decimal Binary Hex
R 255 1111 1111 0xFF
G 0 0000 0000 0x00
B 140 1000 1100 0x8C
= #FF008C
Binary → Hex
00000
00011
00102
00113
01004
01015
01106
01117
10008
10019
1010A
1011B
1100C
1101D
1110E
1111F
Now, there are A LOT of these lightbulbs in digital screens. Hundreds of thousands in early screens, several million in modern ones. Early computers were not very powerful, so a computation that applied to every single pixel, was very expensive. So when we needed to specify a color, it had to be as close to the metal as possible: we'd specify the levels of the dimmers directly. In fact, we'd go one step further and provide them **directly in binary** — well, in hex which is just a convenient way to write binary numbers! Remind you of anything?

Same coordinates, different colors

Macbook Air 2013
Macbook Pro 2026
But usability around color authoring and manipulation is not the only problem with specifying colors through raw subpixel intensities. As different screens have subpixels with different characteristics, throwing raw coordinates at the screen means different things in different devices. Many people think this only applies to the brighter colors, but it affects the entire color space — every color looks different.

Standardized RGB spaces to the rescue!

Macbook Air 2013 60% of sRGB gamut, 8 bits/channel
Macbook Pro 2026 P3 gamut, 10 bits/channel
R G B R G B
Device RGB (raw) 255 0 102 1023 0 408
Device RGB 1 0 0.4 1 0 0.4
sRGB 0.9124 0.27551 0.50037 1.093 -0.2267 -0.1501
Display P3 0.8428 0.32568 0.49946 1 0 0.4
Rec.2020 0.77306 0.40651 0.52267 0.89202 0.2807 0.42666
Instead of throwing raw RGB values at the screen, we would interpret them based on a particular, predefined RGB space and the screen would do the translation to its own native RGB coordinates internally. Instead of being tied to hardware, these color spaces typically use 0-1 ranges and colors outside their gamut have at least one component outside that range. For example, this P3 magenta is outside the sRGB gamut so all three of its components are outside 0-1.

All legacy CSS colors are in sRGB

Color gamut

Range of displayable colors

  • Depends on the primaries (red, green, blue) so often shown as a triangle on the spectral locus
  • All RGB spaces (device-dependent and standardized) have one
  • If any component is outside [0,1] = out of gamut color
It’s all about that gamut
’bout that gamut
’bout that gamut
such trouble 🎵

So what happens if we try to render an out of gamut color?

Clipped Mapped
Guess which one browsers do?

Perceptual uniformity

This is why RGB is a poor choice for gradient interpolation

Usability

RGB is efficient, but *hard* to manipulate by humans or reason about. These hex codes became opaque mystery meat tokens to copy and paste. Universal, but indecipherable except to the most initiated.
As often happens, the closer a UI is to the machine, the farther it is from humans.
rgb(62.5% 45.8% 37.5%)
Light muted brown Dark beige Light brown
About 60% red, a little less green and even less blue — No-one, ever
Let’s try this little experiment. Try to describe this color to the person next to you. Suppose you wanted to explain to your partner what color [something] you wanted them to buy. When we talk about colors, we usually talk about them in terms of a pure hue and some modifiers like light, dark, bright, muted, grayish, greenish etc. Studies disagree on the exact hues and modifiers, but they all find the same general approach.

Polar color spaces

hsl(180 50 50)
Polar color spaces consist of a hue angle plus two components that control ligthness and chroma/saturation. They differ in their characteristics, but all share the goal of improving human usability of an otherwise rectangular color space.

HSL

Fast to compute from RGB but…

hsl(60 100% 50%)
hsl(240 100% 50%)

🤔🤔🤔

Perceptually uniform color spaces

Lab, OKLab, LCH, OKLCH, …

  • Coordinate distance = perceptual distance
  • Polar coords mean the same thing across the space
  • Polar coords are orthogonal

Why?

Custom contrast-color()?


				--l-threshold: 0.7;
				--color1: oklch(from var(--color) sign(clamp(0, var(--l-threshold) - l, 1)) 0 none);
				--color2: contrast-color(var(--color));
			
--color1
--color2

Why OkLab/OkLCH? What's wrong with Lab/LCH?

Ahem…

linear-gradient(to right in srgb, white, blue) linear-gradient(to right in lab, white, blue) linear-gradient(to right in oklab, white, blue)
Color space(s) Usability Device
independence
Perceptual
uniformity
Device RGB 1/5 1/5
Standardized RGB spaces sRGB, Display P3, Rec.2020, ProPhoto, … 1/5 1/5
sRGB polar spaces HSL, HWB, HWB, … 4/5 1/5
Lab 2/5 4/5
LCH 5/5 4/5
OKLab 2/5 5/5
OKLCh 5/5 5/5

Dynamic color ramps?

Imagine if instead of all this…


			

We just needed this…


				:root {
					/* L levels */
					--l-90: 0.97;
					--l-80: 0.88;
					--l-70: 0.76;
					--l-60: 0.66;
					--l-50: 0.56;
					--l-40: 0.47;
					--l-30: 0.38;
					--l-20: 0.29;
					--l-10: 0.19;

					/* Key colors */
					--color-red: oklch(54% 0.24 25);
					--color-orange: oklch(74% 0.19 58);
					--color-yellow: oklch(88% 0.2 95);
					--color-green: oklch(69% 0.24 139);
					--color-cyan: oklch(69% 0.15 205);
					--color-blue: oklch(56% 0.24 260);
					--color-indigo: oklch(50% 0.27 275);
					--color-purple: oklch(57% 0.27 292);
					--color-magenta: oklch(60% 0.25 10);
					--color-gray: oklch(54% 0.04 250);
				}
			

Components own their styling

I am a callout

Some people like to call me an alert, or an admonition. I’m chill either way.


				.callout {
					--color-bg: oklch(from var(--accent-color) var(--l-95) c h);
					--color-border: oklch(from var(--accent-color) var(--l-80) c h);
					--color-icon: oklch(from var(--accent-color) clamp(var(--l-30), l, clamp(var(--l-70))) c h);
					--color-heading: oklch(from var(--accent-color) var(--l-30) c h);
					--color-text: oklch(from var(--accent-color) var(--l-10) c h);
				}
			
Callouts (aka admonitions, alerts, etc) are such a great example for design systems color use cases, because they use many different color tokens, and they have so many variations that their CSS invites combinatorial explosion.

Variations become cheap

Combinatorial explosion no more!


					.danger.callout {
						--accent-color: var(--color-red);
					}
				

Beware

This is a danger callout


					.warning.callout {
						--accent-color: var(--color-yellow);
					}
				

Warning

This is a warning callout


					.success.callout {
						--accent-color: var(--color-green);
					}
				

Success

This is a success callout


					.brand.callout {
						--accent-color: var(--color-brand);
					}
				

Brand

This is a brand callout

…and can be created on the fly

Combinatorial explosion no more!


					.bsky.callout {
						--accent-color: #0a7aff;
					}
				

Bsky

Because why not?


					.magenta.callout {
						--accent-color: var(--color-magenta);
					}
				

Magenta

Because why not?

Key color 90 80 70 60 50 40 30 20 10
Unfortunately, the result left a lot to be desired.

Why?

Why didn't it work??? The question haunted me.
It became an obsession. I built a website to analyze designer crafted color palettes, trying to find patterns. I noticed that chroma was never constant throughout the scale, it dropped dramatically, especially for the lighter colors.
I gaslit myself into thinking we were all asking for the wrong thing. That lighter tints fundamentally need a smaller chroma, and I just had to find the right formula to get close to a designer-crafted color scale. So I started looking.
Key color 90 80 70 60 50 40 30 20 10
People often create dynamic scales by mixing with white or black. The problem with this is that you’re desaturating at the same time as darkening/lightening, and you have no control over it. The resulting tints are often washed out. Also, if we naively mix a predefined amount we get no guarantees about each tint except relative to its key color. It’s the HSL problem all over again.

Early experiments mixing the two

People often create dynamic scales by mixing with white or black. The problem with this is that you’re desaturating at the same time as darkening/lightening, and you have no control over it. The resulting tints are often washed out. Also, if we naively mix a predefined amount we get no guarantees about each tint except relative to its key color. It’s the HSL problem all over again.
One day, it dawned on me. I was playing with this color picker component I've made when I noticed that when the color got ligther, it was always out of gamut — not just P3, but any reasonable gamut.
So I made another app to explore this hypothesis further. Turns out that indeed — the lighter or darker you go, the smaller the gamut gets, down to a single point at 0 and 1. It wasn't that we didn’t know what we wanted. It was that we were not getting the color we asked for.
Indeed, look here how close just gamut mapping gets us! It's a pretty acceptable result upfront. And with a little tweaking, we'd _be_ there. There was only one problem. Browsers did not implement gamut mapping. _But what if we could implement it ourselves?_

Out of gamut is the Wild West

* even in spaces that generally have these properties

Implementing gamut mapping in CSS?!

There is always a chroma that brings the color in gamut

we just need to find it


			--c-90-max: max(min(0.246,
					max((0.55 / (103 - h) + 0.015),
						(1 / (h - 106) + 0.031) * ((170 - h) / abs(170 - h)))
				), min(0.053, 0.8 / abs(h - 180) + 0.008),
				0.037 - sqrt(abs((h - 330) * 0.00001))
			);
			--steps-90: (round(down, (var(--l-90) - l), 0.1) / 0.1);
			--c-90: min(c * (1 - var(--steps-90) * 0.15), var(--c-90-max));
			--tint-90: var(--l-90) var(--c-90) h;

			/* Used like: */
			background: oklch(from var(--color) var(--tint-90));
		
I think this is the closest CSS ever got me to losing my mind. This is a real formula from one of my attempts: basically trying to fit the gamut edge curve numerically via a RCS formula. Do NOT try this at home.
iterations
Callout
--set-l: .97 c h;
--clamp-s: h clamp(0, s, 100) l;
--color: {{ color }};
--color-90-{{i}}: oklch(from var(--color{{ i-1 ? '-90-' + (i-1) + '-hsl' : '' }}) var(--set-l));
--color-90-{{i}}-hsl: hsl(from var(--color-90-{{i}}) var(--clamp-s));
--color-90: oklch(from var(--color{{ n ? `-90-${n}-hsl` : '' }}) var(--set-l));
iterations
--color-lh: oklch(from var(--color) l none h);
--clamp-s: h clamp(0, s, 100) l;
--color: {{ color }};

--color-{{i}}-hsl: hsl(from var(--color{{ i-1 ? '-' + (i-1) : '' }}) var(--clamp-s));
--color-{{ i < n ? i : 'mapped' }}: color-mix(in oklch, var(--color-{{i}}-hsl) 0%, var(--color-lh));

Relative color syntax with 2 colors?


			--color: oklch(
				from var(--color-1) var(--color-2)
				l c2 h
			);
		

CSS WG approved but not yet specced

none values

`none` was originally invented to represent the missing hues in grayscale colors.

none is awesome


--color: oklch(0.6 0.3 350);
--img: linear-gradient(to right in oklab,
	var(--color), oklab(0.5 0 0));
--img2: linear-gradient(to right in oklab,
	var(--color), oklab(0.5 none none));
		

none is awesome


--color: oklch(0.6 0.3 350);
--color2: oklch(0.8 0.2 80 / 50%);
--img1: linear-gradient(to right in oklch,
	var(--color), var(--color2));
--img2: linear-gradient(to right in oklch,
	oklch(from var(--color) none c h / alpha),
	oklch(from var(--color2) l none h / none));
		

Emulate multi-color RCS with none


--color: oklch(0.6 0.3 350);
--color2: oklch(0.8 0.2 80 / 50%);
--mix1: color-mix(in oklch, var(--color), var(--color2));
/* oklch(from var(--color1) var(--color2)
	l2 c calc((h + h2)/2) / alpha) */
--mix2: color-mix(in oklch,
	oklch(from var(--color) none c h / alpha),
	oklch(from var(--color2) l none h / none));
		

💡 Near future: Recursive @function?


			@function --gamut-map(--color <color>, --i <integer>: 1) returns <color> {
				--color-lh: oklch(from var(--color) l none h);
				--clamp-s: h clamp(0, s, 100) l;
				--color-hsl: hsl(from var(--color) var(--clamp-s));
				--color-mapped: color-mix(in oklch, var(--color-hsl) 0%, var(--color-lh));
				result: if(
					style(--i: 0): var(--color-mapped);
					else: --gamut-map(var(--color-mapped), calc(var(--i) - 1))
				);
			}
		
if() does not short circuit, recursive not supported

💡 Attempt No 2


			@function --gamut-map-iteration(--color <color>) returns <color> {
				--color-lh: oklch(from var(--color) l none h);
				--clamp-s: h clamp(0, s, 100) l;
				--color-hsl: hsl(from var(--color) var(--clamp-s));
				result: color-mix(in oklch, var(--color-hsl) 0%, var(--color-lh));
			}

			@function --gamut-map(--color <color>, --i <integer>: 1) returns <color> {
				--color-1: --gamut-map-iteration(var(--color));
				--color-2: --gamut-map-iteration(var(--color-1));
				--color-3: --gamut-map-iteration(var(--color-2));
				--color-4: --gamut-map-iteration(var(--color-3));
				result: --gamut-map-iteration(var(--color-4));
			}
		

tl;dr No nesting allowed rn, but it will be fixed

Gamut mapped scales

Key color 90 80 70 60 50 40 30 20 10
And here's the same constant-chroma oklch scale, but gamut mapped to sRGB. Each tint keeps its lightness and hue; we just reduce chroma until it fits. No washing out, no out-of-gamut surprises — and it's remarkably close to a hand-crafted palette.