OKLCH and modern CSS color: why HSL lies about lightness
HSL was a large improvement over hex when it arrived, because it let you reason about a color in terms you could describe: this hue, this saturation, this lightness. It has one significant flaw, which becomes obvious the moment you try to build a palette. hsl(60 100% 50%) is a blinding yellow. hsl(240 100% 50%) is a dark navy. Both claim 50% lightness. HSL's lightness is a geometric position in a color model, not a measurement of how bright the color looks, and human vision is far more sensitive to green and yellow wavelengths than to blue.
OKLCH fixes exactly that, and it is supported in every current browser. You can convert between hex, RGB, HSL, and check contrast, with the color converter and contrast checker.
What perceptual uniformity means
A color space is perceptually uniform when equal numeric changes produce equal perceived changes. OKLCH is built on the Oklab model, which was fitted to how people actually report seeing color differences. In practice this means:
- Two colors with the same
Llook equally bright, regardless of hue. - Changing
Hwhile holdingLandCchanges the hue and nothing else. - A ramp of evenly spaced
Lvalues looks evenly spaced.
None of those hold in HSL, which is why HSL palettes need per hue hand correction: your blues come out too dark, your yellows too light, and you end up nudging lightness values until the row looks right, which is work the color space should have done.
The three channels
color: oklch(0.72 0.19 250);
color: oklch(0.72 0.19 250 / 0.5); /* with alpha */
L, lightness, 0 to 1. Perceptual, so 0.5 is a genuine mid tone in every hue. 0 is black, 1 is white. This is the channel you vary for a tint ramp.
C, chroma, 0 upward. Colorfulness, and unlike HSL saturation it is not a percentage of some maximum. It is unbounded in principle; the practical ceiling depends on the lightness and hue and on the display. sRGB tops out around 0.37; a P3 display goes further. Values above what the display supports are clipped, or gamut mapped by the browser, so a very high chroma at an extreme lightness may not render as specified.
H, hue, 0 to 360 degrees. Roughly: 20 red, 70 orange, 110 yellow, 145 green, 195 cyan, 250 blue, 330 magenta. The numbers do not match HSL's hue wheel, because the spacing has been corrected for perception; converting an existing palette means converting, not renumbering.
Building a palette with it
The practical payoff is that a palette becomes arithmetic rather than eyeballing. Hold hue and chroma, step lightness:
:root {
--brand-hue: 250;
--brand-chroma: 0.16;
--brand-100: oklch(0.95 calc(var(--brand-chroma) * 0.3) var(--brand-hue));
--brand-300: oklch(0.82 calc(var(--brand-chroma) * 0.7) var(--brand-hue));
--brand-500: oklch(0.65 var(--brand-chroma) var(--brand-hue));
--brand-700: oklch(0.48 var(--brand-chroma) var(--brand-hue));
--brand-900: oklch(0.30 calc(var(--brand-chroma) * 0.8) var(--brand-hue));
}
Every step is a genuine perceptual step, and swapping --brand-hue for another value regenerates a complete ramp with identical contrast behaviour. That last property is what makes theming with OKLCH practical: a green theme and a blue theme derived the same way will have the same readability, which is not true of an HSL palette where the same lightness values produce different apparent contrast per hue.
Chroma tapering, reducing chroma at the extremes as above, matters because very light and very dark colors cannot hold high chroma. Requesting it produces a clipped, slightly off color instead of the one you specified.
Interpolation and gradients
Gradients between colors are computed in a color space, and the choice shows. Interpolating in sRGB between complementary colors passes through a desaturated grey band in the middle, the well known muddy gradient. Interpolating in OKLCH keeps chroma up and travels around the hue wheel instead:
background: linear-gradient(in oklch, oklch(0.7 0.2 20), oklch(0.7 0.2 250));
background: linear-gradient(in oklch longer hue, red, blue);
The in <space> syntax works on gradients and on color-mix(), which is the modern way to derive a hover state or a subtle tint without maintaining another variable:
--hover: color-mix(in oklch, var(--brand-500), white 15%);
The same reasoning applies to CSS gradients generally; the CSS gradient generator and CSS gradients explained cover the syntax in full.
Contrast is still measured separately
An important caveat: OKLCH lightness is perceptual, but it is not the same measurement WCAG uses. WCAG 2 contrast ratios are computed from relative luminance in sRGB, and that formula has known weaknesses, particularly for dark themes and for colored text. A pair of colors with a large L difference in OKLCH is usually but not always compliant.
So build the palette in OKLCH and then verify the pairs you actually use with the contrast checker, which computes the ratio the standard defines. The thresholds, 4.5:1 for body text, 3:1 for large text and interface components, are explained in WCAG color contrast requirements. Do not substitute intuition about lightness for the measurement, in either direction.
Wider gamuts
OKLCH also addresses something hex cannot express at all. A hex code is by definition an sRGB value, and modern displays cover considerably more than sRGB. OKLCH is not tied to a gamut, so it can specify colors sRGB cannot represent, with a query to scope them:
.accent { background: oklch(0.65 0.16 250); }
@supports (color: color(display-p3 1 0 0)) {
@media (color-gamut: p3) {
.accent { background: oklch(0.68 0.28 250); }
}
}
Browsers gamut map out of range colors to the nearest displayable one rather than failing, so the fallback is graceful, but declaring both is clearer about intent.
Should you switch?
For new work, yes: the syntax is supported everywhere current, the palette maths is simpler, and gradients and mixing behave. For existing projects, the pragmatic path is to convert the design tokens and leave everything else alone, since the tokens are where the per hue hand correction lives.
Converting is mechanical, and you can do it a color at a time with the color converter, which handles hex, RGB, and HSL alongside contrast checking. The one thing worth doing by hand is re deriving the ramp rather than converting each existing step: converted HSL steps inherit the uneven spacing you were compensating for, and rebuilding from lightness values gives you the palette the design was reaching for in the first place.