Vivian Voss

OKLCH: The Colour System That Does Not Lie

css web

Stack Patterns ■ Episode 01

“For thirty years, CSS has been lying to you about colour. It was doing it with a straight face the entire time.”

In 1993, HEX arrived. #FF6600. Six characters that tell a human being precisely nothing about what the colour looks like. It is a memory address for a pixel. Useful for machines. Useless for designers. The industry adopted it immediately and has been squinting at it ever since.

RGB followed the same logic with decimal courtesy: rgb(255, 102, 0). Three channels, zero intuition. To make a colour “a bit lighter,” you increment all three values by some amount and hope for the best. It is colour by spreadsheet.

Then came HSL, and the industry exhaled. Hue, Saturation, Lightness: finally, a model that speaks human. Rotate the hue wheel, adjust saturation, slide the lightness. Intuitive. Elegant. And, as it happens, fundamentally dishonest.

The HSL Lie

HSL promises that the “L” channel represents perceived lightness. Set two colours to the same lightness value, and they should appear equally bright to the human eye. This is the contract. HSL violates it spectacularly.

Consider: hsl(60, 100%, 50%) is yellow. hsl(240, 100%, 50%) is blue. Same saturation. Same “lightness.” Same L value of 50%. One blinds you. The other is barely visible against a dark background. They are, according to HSL, identically bright. Your retina has a rather different opinion.

HSL LIGHTNESS vs OKLCH LIGHTNESS Same “lightness” value, vastly different perceived brightness HSL : mathematical lightness hsl(60, 100%, 50%) hsl(240, 100%, 50%) Yellow → L = 50% Blue → L = 50% ✘ blindingly bright ✘ barely visible OKLCH : perceptual lightness oklch(90% 0.18 100) oklch(45% 0.16 265) Yellow → L = 90% Blue → L = 45% ✓ honestly bright ✓ honestly dark 10% L difference = 10% perceived difference

The reason is that HSL’s lightness is a mathematical transformation of RGB, not a perceptual measurement. It divides the colour space evenly by numbers, not by how the human visual system processes those numbers. Yellow reflects far more light than blue at the same mathematical “lightness.” HSL does not account for this. It does not try. It simply lies, politely and consistently, and hopes nobody compares the swatches.

Enter OKLCH

In 2020, Björn Ottosson published Oklab, a perceptual colour space designed from first principles to match human vision. OKLCH is its cylindrical representation, the same colour space expressed as Lightness, Chroma, and Hue rather than Cartesian coordinates. Three axes, each doing exactly what it claims:

L: Lightness. Perceptually uniform. When you change L by 10%, the colour actually looks 10% brighter or darker to the human eye. Not “10% brighter according to a formula that ignores the human eye.” Actually brighter. This is the promise HSL made and could not keep.

C: Chroma. Colour intensity, from grey to vivid. Unlike HSL’s saturation, chroma in OKLCH is absolute. A chroma of 0.12 means the same intensity regardless of hue. You can desaturate by reducing C without side effects on perceived lightness.

H: Hue. The hue angle, 0-360 degrees. Rotating the hue changes the colour without affecting lightness or chroma. This sounds obvious. In HSL, rotating from yellow to blue at constant S and L produces a dramatic perceived brightness shift. In OKLCH, it does not.

The practical consequence: you can build an entire colour palette by fixing L and C, then rotating H. Every colour in the palette will have the same perceived brightness and intensity. No manual adjustment. No optical illusions. No squinting at swatches and wondering why the blue feels heavier than the green.

Beyond sRGB

There is a second advantage, and it is not subtle. OKLCH in CSS supports colour gamuts beyond sRGB. Display P3, the wide gamut that modern Apple, Samsung, and flagship displays have shipped for years, offers roughly 50% more colours than sRGB. Rec2020 goes further still. HEX and RGB are physically incapable of addressing these colours. They are locked to the sRGB gamut, which was defined in 1996 for CRT monitors that no longer exist.

OKLCH addresses the full range. If the display supports it, the colour renders. If the display does not, the browser gamut-maps to the nearest displayable colour automatically. No fallback stacks. No @supports queries for colour. The specification handles the degradation.

The Syntax

Define a brand colour once:

--brand: oklch(60% 0.12 250);

That is: 60% perceived lightness, moderate chroma, hue angle 250 (a confident blue). Now derive every variant you need without touching a colour picker:

--brand-light: oklch(from var(--brand) calc(l + 0.2) c h);
--brand-dark:  oklch(from var(--brand) calc(l - 0.2) c h);
--brand-muted: oklch(from var(--brand) l calc(c - 0.06) h);
--brand-vivid: oklch(from var(--brand) l calc(c + 0.06) h);

This is Relative Color Syntax. The from keyword destructures the source colour into its channels. calc() adjusts individual channels mathematically. The result is a derived colour that maintains the perceptual relationships because the colour space itself guarantees them. Change the brand colour once; every variant follows automatically. No Sass functions. No JavaScript. No colour library.

light-dark(): The Theme Switch

Dark mode. The feature that launched a thousand media queries, a hundred CSS custom property toggles, and at least one npm package too many. The platform solution is two declarations:

:root {
    color-scheme: light dark;
}

--bg:      light-dark(oklch(98% 0.01 250), oklch(15% 0.01 250));
--surface: light-dark(oklch(95% 0.01 250), oklch(20% 0.02 250));
--text:    light-dark(oklch(20% 0.01 250), oklch(90% 0.01 250));

The light-dark() function accepts two values: the first for light mode, the second for dark. The browser selects the appropriate value based on the computed color-scheme. No JavaScript toggle. No @media (prefers-color-scheme) duplication. No class on the body element. The operating system preference propagates through CSS directly, and the function resolves at computed-value time.

Combined with OKLCH, the ergonomics are rather elegant: your light and dark variants live side by side in the same declaration, expressed in a colour space where the lightness values actually correspond to what the user sees. 98% is near-white. 15% is near-black. No ambiguity. No conversion tables. No hoping that #1a1a2e and #fafafa will produce adequate contrast.

THE THREE-LAYER PATTERN Define once → derive → theme. No JavaScript. 1. OKLCH: Perceptual Truth --brand: oklch(60% 0.12 250) 2. Relative Color Syntax: Derived Variants oklch(from var(--brand) calc(l + 0.2) c h) light ■ dark ■ muted ■ vivid, all from one source 3a. light-dark() light-dark(light, dark) 3b. color-scheme color-scheme: light dark One brand definition → entire design system. 0 KB JavaScript.

Browser Support

OKLCH: Chrome 111+, Firefox 113+, Safari 15.4+. Global coverage: 93%. Relative Color Syntax is somewhat newer (Chrome 119+, Firefox 128+, Safari 16.4+) but the core oklch() function has been universally available since early 2023. light-dark() shipped in Chrome 123+, Firefox 120+, Safari 17.5+.

These are not experimental features behind a flag. They are shipping, stable, interoperable CSS. The browser support exceeds that of several features the industry deploys without a second thought. The only prerequisite is the willingness to stop writing #FF6600.

The Deeper Point

For three decades, the web’s colour tooling was built on a foundation that did not match human perception. Designers compensated manually. Design systems shipped lookup tables. Sass functions performed conversions that approximated what the colour space should have guaranteed. The tooling existed because the primitive was broken.

OKLCH is not a better version of HSL. It is a correct implementation of what HSL pretended to be. Lightness means lightness. Chroma means chroma. Hue rotation does not produce brightness side effects. The colour space itself enforces the perceptual relationships that design systems previously had to enforce in code.

Combined with Relative Color Syntax and light-dark(), the pattern is three layers deep and zero kilobytes of JavaScript wide: define a brand colour in OKLCH, derive variants with calc(), switch themes with a built-in function. The entire colour architecture lives in CSS custom properties. No build step. No runtime. No library. A colour system that tells the truth, a derivation mechanism that respects it, and a theme function that the browser resolves before your JavaScript has finished parsing.

For thirty years, CSS lied about lightness. In 2023, it stopped. The colour swatches, at long last, match the specification.