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.
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.
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.