Why HSL lies to you, and what to use instead
HSL claims that every hue at 50% lightness is equally light. Look at pure yellow next to pure blue and you can see that it is not true. Here is what that costs you.
↑ Click any of these to repaint the entire site with it.
Here are six colors. Every one of them is hsl(hue, 100%, 50%). According to HSL they are all exactly the same lightness.
They are not the same lightness. The yellow is blinding and the blue is nearly black. Measured properly, the yellow has a relative luminance of 0.93 and the blue has 0.07 — a ratio of about thirteen to one between two colors that HSL insists are identical in lightness.
This is not a rounding error. It is the fundamental design of the space.
Where the number comes from#
HSL's "lightness" is (max + min) / 2 over the RGB channels. For pure yellow that is (1 + 0) / 2 = 0.5. For pure blue that is also (1 + 0) / 2 = 0.5. The formula never asks which channel is at the maximum, and the answer matters enormously — human vision is roughly seven times more sensitive to green light than to blue, and yellow is red plus green.
HSL was designed in the 1970s to be cheap to compute on hardware that could not afford anything better. It succeeded at that. It was never designed to model perception, and it does not.
What it costs you in practice#
Your harmonies come out lopsided. Rotate 120° from a yellow in HSL and you get a cyan that is visibly darker. The triad is mathematically even and visually broken, so you spend an afternoon nudging each color by hand and never work out why the maths did not save you the time.
Your ramps stall and then lurch. Generating a scale by stepping HSL lightness from 90% down to 10% gives even numbers and uneven perception. The light end barely moves and the dark end falls off a cliff.
Your "same lightness" states are not. Setting hover as "the same color, ten percent lighter" produces a different amount of visible change on every hue in your palette.
What to use instead#
OKLCH. Same three ideas as HSL — lightness, colorfulness, hue — but built on a model that was fitted to how people actually see. It is in every current browser and it is a plain CSS color function.
/* HSL: these claim to be the same lightness */
--a: hsl(60 100% 50%);
--b: hsl(240 100% 50%);
/* OKLCH: these actually are */
--c: oklch(70% 0.16 60);
--d: oklch(70% 0.16 240);| HSL | OKLCH | |
|---|---|---|
| Lightness | (max+min)/2 on RGB | fitted to perceptual data |
| Equal L looks equal | No | Yes |
| Hue rotation preserves lightness | No | Yes |
| Can express colors beyond sRGB | No | Yes |
| Maximum chroma | always 100% | depends on L and H |
That last row is the one that trips people up.
Chroma is not saturation#
In HSL, saturation is a percentage of some notional maximum, so 100% is always available. In OKLCH, chroma is an absolute quantity, and how much of it your screen can actually display depends on both the lightness and the hue.
There is no such thing as a light, fully saturated blue in sRGB. Not because OKLCH is awkward, but because the display cannot make one. HSL hides that by letting you write hsl(240 100% 90%) and quietly handing you a pale lavender. OKLCH makes you confront it, which feels like friction right up until the first time it stops you shipping a ramp whose light end is a neon smear.
The migration is cheap#
You do not need to rewrite anything. Author new colors in OKLCH, keep hex for the values you already shipped, and let the two coexist — they are both just CSS colors.
:root {
--brand: oklch(62% 0.18 258);
--brand-hover: oklch(from var(--brand) calc(l + 0.06) c h);
--brand-muted: oklch(from var(--brand) l calc(c * 0.4) h);
}That second line is relative color syntax, and it is the thing that makes the switch worth it. A hover state defined as "six points lighter" is a rule your whole team can read, and it produces the same amount of visible change on every color you apply it to. In HSL, the same rule produces a different result on every hue — which is why nobody ever wrote it down.