What HSL lightness measures
HSL is a geometric re-shaping of the RGB cube into a double cone. Its
L is the midpoint between the largest and smallest RGB channel:
(max + min) / 2. That is an arithmetic fact about three numbers,
and it has no relationship to how bright the result looks.
The clearest demonstration is the pair every article about this reaches for,
because nothing else makes the point as fast. hsl(60 100% 50%)
is pure yellow. hsl(240 100% 50%) is pure blue. Identical
L. Their WCAG relative luminances are 0.928 and 0.072 — a factor
of thirteen. Against white, the yellow gives 1.07:1 and the blue 8.59:1.
Saturation has the same problem in a smaller way: S is
normalised against the maximum possible chroma at that lightness,
so 100% saturation means something different at L 20% than at L 80%.
The uneven ramp, with numbers
Take a blue at hue 240 and step lightness evenly in HSL — 95%, 80%, 65%, 50%, 35%, 20%. Perfectly regular numbers. Now read the perceptual lightness of each step, measured in OKLCH:
| HSL L | Hex | OKLCH L | Step size |
|---|---|---|---|
| 95% | #E5E5FF | 93.0% | — |
| 80% | #9999FF | 73.6% | 19.4 |
| 65% | #4D4DFF | 55.4% | 18.2 |
| 50% | #0000FF | 45.2% | 10.2 |
| 35% | #0000B3 | 35.1% | 10.1 |
| 20% | #000066 | 23.1% | 12.0 |
The steps are not even: two gaps of about 19, then two of about 10. In a 50–950 token scale that shows up as a ramp where 100 and 200 are nearly the same color while 400 and 500 are visibly far apart — and it shows up differently for every hue, so a blue ramp and a yellow ramp built the same way do not correspond step for step.
That correspondence is the whole reason ramps are numbered. If
blue-500 and green-500 are not the same perceived
lightness, swapping one for the other changes contrast, and every token that
was measured against a background has to be re-measured.
What OKLCH does differently
OKLab, published by Björn Ottosson in 2020, is built from the response of the
eye's three cone types rather than from the RGB cube. Its L is a
perceptual lightness: equal numeric differences look like equal differences
in brightness, across hues. OKLCH is the same space in polar coordinates —
lightness, chroma, hue angle — which is the form that is convenient to author
in.
Three practical consequences:
- Even ramps. Step
Levenly and the ramp looks even, for any hue. - Cross-hue correspondence.
blue-500andamber-500sit at the same perceived lightness, so they are interchangeable in a layout. - Predictable mixing. Interpolating between two colors in OKLCH stays in the hue family. The same interpolation in sRGB passes through a desaturated middle — which is why a blue-to-yellow CSS gradient goes grey halfway across.
Chroma differs from saturation in a way worth internalising: C
is an absolute distance from grey, not a percentage of a maximum. It has no
upper bound of 1. sRGB colors top out around 0.37; a typical UI blue is
around 0.15; a neutral with a hint of warmth is 0.01–0.03.
The syntax
/* lightness chroma hue */
color: oklch(62% 0.19 255);
/* with alpha */
color: oklch(62% 0.19 255 / 0.6);
/* relative color syntax: same hue, lighter */
--hover: oklch(from var(--primary) calc(l + 0.08) c h);
Lightness accepts a percentage or a 0–1 number. Chroma is a plain number. Hue is an angle in degrees, and unlike HSL the hue numbers are not the same ones you know — OKLCH hue 255 is a blue, 145 a green, 30 an orange.
Gamut mapping
OKLCH can describe colors that no sRGB display can produce.
oklch(70% 0.35 140) is a perfectly valid coordinate and a green
far outside the sRGB triangle. Rendering it means choosing what to sacrifice.
Clipping — clamping each RGB channel into 0–255 — is the naive approach, and it shifts hue. Clipping a saturated green usually pins the green channel at maximum while red and blue move independently, and the color that comes out is not the hue that went in. In a ramp this is visible: the saturated end drifts out of the family.
Chroma reduction is the better trade. Hold L
and h, binary-search C down until the color fits
inside sRGB, and accept a less saturated color of the right hue and
lightness. Every color on this site is mapped that way, which is why a ramp
built from a vivid input stays recognisably one hue from 50 to 950.
CSS has a native version of the same idea: @media (color-gamut: p3)
lets you serve a wider-gamut value to displays that can show it, with an
sRGB value as the default.
Browser support and fallbacks
oklch() ships in current Chrome, Edge, Safari and Firefox. Two
fallback strategies, depending on how far back you need to go:
/* Cascade fallback: an old browser cannot parse the second
declaration and keeps the first. */
.button {
background: #1f5fd0;
background: oklch(48% 0.17 258);
}
/* Feature query, when more than one property depends on it. */
@supports (color: oklch(0% 0 0)) {
:root { --primary: oklch(48% 0.17 258); }
}
The third option, and the most common in production: author in OKLCH, and convert to hex at build time. You keep the perceptual math where the ramps are generated and ship values every browser has understood for thirty years. That is what the palette builder does when it exports.
When HSL is still the right answer
- Nudging one color by eye. If you know HSL, you can guess what raising S by 10 will do. OKLCH takes a while before that intuition transfers.
- Legacy pipelines. Tools, plugins and stakeholders that speak HSL are not worth fighting for a color that is already chosen.
- Rough hue rotation. Shifting hue by a fixed number of degrees is fine in either space if you are not depending on the lightness staying put.
What HSL should not be used for is generating a ramp, comparing two colors' lightness, or interpolating a gradient. Those are exactly the cases where its lightness axis is not measuring what you think it is.
Checklist
- Ramps generated by stepping OKLCH lightness, not HSL lightness
- Steps of the same number across hues land at the same perceived lightness
- Out-of-gamut colors mapped by reducing chroma, not by clipping channels
- Gradients interpolated in OKLCH so the midpoint keeps its chroma
- A hex fallback, a feature query, or build-time conversion for older browsers
- Contrast still measured with the WCAG ratio — OKLCH lightness is not a contrast test
Frequently asked questions
Is OKLCH better than HSL?
For generating ramps, adjusting lightness and mixing colors, yes — its lightness axis matches perception, so equal numeric steps look equal. For hand-tweaking a single color by eye, HSL is fine and more familiar. The practical answer is to compute in OKLCH and ship whatever format your pipeline needs.
Can I use oklch() in CSS today?
Yes. oklch() is supported in current Chrome, Edge, Safari and Firefox. If you need to support older browsers, either provide a hex fallback declaration before the oklch() one — an unsupported browser ignores the declaration it cannot parse — or convert to hex at build time and keep OKLCH as the authoring space only.
What is the difference between OKLCH and OKLab?
They are the same color space in different coordinates. OKLab is cartesian: lightness plus two opponent axes, a and b. OKLCH is the polar form of the same values: lightness, chroma (distance from grey) and hue angle. OKLCH is easier to author with because hue and saturation are separate numbers; OKLab is easier to compute distances in.
Why does my OKLCH color come out different from what I typed?
Because it fell outside sRGB and was gamut-mapped. OKLCH can describe colors no sRGB screen can show — oklch(70% 0.35 140) is one of them. Something has to give, and the least damaging option is to reduce chroma while holding lightness and hue, which is what a well-behaved converter does. Clipping the RGB channels instead is faster and shifts the hue.
Sources
- Björn Ottosson, "A perceptual color space for image processing" (2020), the original definition of OKLab
- CSS Color Module Level 4, which specifies
oklch(),oklab()and gamut mapping