OKLCH vs HSL: the end of guessing at lightness
HSL lies about lightness, and every palette built on it inherits the lie. OKLCH tells the truth, which turns out to change how you build color ramps entirely.
Here is a demonstration you can run in your head. Two colors, identical in HSL except for hue:
hsl(60, 100%, 50%), a yellowhsl(240, 100%, 50%), a blue
Both claim 50% lightness. Put them next to each other and the yellow is blinding while the blue is nearly black. Their actual relative luminances are 0.928 and 0.072, a factor of almost thirteen.
HSL’s “lightness” is not a measure of light. It is the midpoint of the largest and smallest RGB channel, which is a convenient thing to compute and has nothing to do with vision. Every palette generated by stepping HSL lightness inherits this.
What OKLCH changes
OKLCH is the polar form of OKLab, published by Björn Ottosson in 2020. It has three components:
- L is lightness, from 0 to 1, and it actually matches perception. Two colors at L 0.6 look equally bright regardless of hue.
- C is chroma, how colorful the color is. Unbounded in principle, roughly 0 to 0.37 for colors that fit in sRGB.
- H is the hue angle, from 0 to 360, the same idea as the hue in HSL.
OKLCH vs HSL, side by side
| HSL | OKLCH | |
|---|---|---|
| Yellow | hsl(60 100% 50%) |
oklch(96.8% 0.211 109.8) |
| Blue | hsl(240 100% 50%) |
oklch(45.2% 0.313 264.1) |
OKLCH reports what your eyes already told you: the yellow is near the top of the lightness range and the blue sits below the middle. The numbers are now trustworthy.
Why this matters for a design system
The classic task is a ramp from 50 to 950 for each brand color. In HSL you do it by stepping lightness evenly and then correcting every step by hand, because the light end goes chalky and the dark end goes muddy. Different hues need different corrections, so nothing is reusable.
In OKLCH you pick a lightness ladder once and apply it to every hue:
:root {
--brand-hue: 255;
--brand-chroma: 0.14;
--brand-50: oklch(97.1% calc(var(--brand-chroma) * 0.25) var(--brand-hue));
--brand-100: oklch(93.6% calc(var(--brand-chroma) * 0.40) var(--brand-hue));
--brand-300: oklch(80.8% calc(var(--brand-chroma) * 0.75) var(--brand-hue));
--brand-500: oklch(60.6% var(--brand-chroma) var(--brand-hue));
--brand-700: oklch(45.0% calc(var(--brand-chroma) * 0.90) var(--brand-hue));
--brand-900: oklch(32.5% calc(var(--brand-chroma) * 0.65) var(--brand-hue));
}
Change --brand-hue and the entire ramp takes on the new hue while keeping its lightness
structure. Every 500 in your system is the same perceived brightness, so a green button and
a red button have the same visual weight without anyone tuning them by hand.
The chroma multipliers matter. Chroma has to taper towards both ends: a very light color physically cannot hold much chroma without looking dirty, and a very dark one cannot hold much without looking neon. A curve peaking around the 500 and 600 band is what makes a generated ramp look designed. The color converter builds exactly this ramp from any color you paste in, so it doubles as a Tailwind color shades generator.
Gamut, and what happens outside it
OKLCH can describe colors your monitor cannot show. oklch(70% 0.35 150) is a green more
saturated than sRGB can produce. Browsers handle this by gamut mapping, pulling chroma down
until the color fits. That is graceful, but it means two different OKLCH values can render
identically.
If you are targeting displays with a wide gamut, @media (color-gamut: p3) lets you serve
the richer values to the screens that can show them, with an sRGB set underneath.
Should you ship it?
Yes, for most projects. oklch() is supported in all current major browsers. A hex fallback
covers the rest:
.button {
background: #3d5a80; /* older browsers */
background: oklch(45% 0.07 255); /* everyone else */
}
The honest caveat: OKLCH is not a magic wand for choosing colors. It will not tell you which hue suits your brand. What it does is make the numbers mean what they say, so that when you decide two things should look equally bright, you can express that and be right.