Same Lightness, 12× the Brightness: HSL vs OKLCH
Every designer has hit this and most of us blamed ourselves. You build a palette in HSL, pick lightness 50% for the mid tone of every hue so the set is "consistent", and then the yellow looks like it is glowing and the blue looks like a hole in the page. You nudge the yellow down to 45%, the blue up to 55%, and now the numbers are arbitrary and the next person to touch the file has no idea why.
The file was not the problem. HSL's lightness channel does not describe brightness, and I wanted to know by how much. So I measured it.
The measurement
Both HSL and OKLCH have a channel called lightness. The test is simple: hold that channel fixed, sweep the hue through 360 degrees, and compute the WCAG relative luminance of the resulting colour. If the model's lightness means anything perceptually, luminance should stay roughly flat.
Here is HSL at lightness 50%, saturation 100% — the settings behind most hand-built "500" tokens:
| Hue | Hex | Relative luminance | Contrast vs white |
|---|---|---|---|
| 0 | #ff0000 |
0.2126 | 4.00 |
| 60 | #ffff00 |
0.9278 | 1.07 |
| 120 | #00ff00 |
0.7152 | 1.37 |
| 180 | #00ffff |
0.7874 | 1.25 |
| 240 | #0000ff |
0.0722 | 8.59 |
| 300 | #ff00ff |
0.2848 | 3.14 |
Yellow at HSL 50% is 12.9 times brighter than blue at HSL 50%. Same number in the lightness slot, an order of magnitude apart on screen.
Now OKLCH at lightness 0.62, chroma 0.14:
| Hue | Hex | Relative luminance | Contrast vs white |
|---|---|---|---|
| 0 | #c85e83 |
0.2180 | 3.92 |
| 60 | #c26e12 |
0.2269 | 3.79 |
| 120 | #7c9217 |
0.2476 | 3.53 |
| 180 | #00a089 |
0.2706 | 3.28 |
| 240 | #0a8fd1 |
0.2432 | 3.58 |
| 300 | #9470cd |
0.2230 | 3.85 |
Spread across the full sweep: 12.9× for HSL, 1.2× for OKLCH.
The contrast column is the real story
Luminance is abstract. Contrast ratio is the thing that gets you a failing accessibility audit, and it is where the HSL numbers stop being an aesthetic complaint and start being a bug.
In the HSL table, the yellow sits at 1.07:1 against white. That is not "slightly low" — WCAG's minimum for normal body text is 4.5:1, and 1.07 is very nearly invisible. The blue at the same nominal lightness is 8.59:1, comfortably past AAA. If your design system says "use the 500 token for icons on white", that instruction produces a perfectly readable icon in blue and an unusable one in yellow, and nothing in the token names tells anyone.
Every OKLCH row lands between 3.28 and 3.92. Still short of 4.5:1 for body text — that is a chroma and lightness choice, not a model failure — but the point is that one decision fixes or breaks the whole row at once. You can lower the lightness until the worst hue passes, and know the rest came with it. In HSL there is no such lever.
Why HSL behaves this way
HSL's lightness is not a perceptual quantity. It is the arithmetic midpoint of the largest and smallest RGB channel:
L = (max(R, G, B) + min(R, G, B)) / 2
For pure yellow, #ffff00, that is (1 + 0) / 2 = 0.5. For pure blue, #0000ff, also (1 + 0) / 2 = 0.5. The formula never asks which channels are lit, and the eye cares enormously — the standard luminance weights are 0.2126 for red, 0.7152 for green and 0.0722 for blue. Yellow is red plus green, so it carries almost all of the visual weight the eye is tuned for. Blue carries 7%.
HSL is a trivially cheap transform of RGB, designed in the 1970s to give artists a usable dial on hardware that had no cycles to spare. It was never a model of human vision, and it is not one now.
OKLCH is the polar form of Oklab, which is fitted against perceptual datasets and applies a cube-root compression to cone response estimates before mixing them. Its lightness channel is built to correlate with what you actually perceive, so holding it fixed holds appearance fixed. The oklch() CSS function has been supported across every major browser engine since 2023, which is what moved this from a colour-science conversation into something you can just type into a stylesheet.
If HEX, RGB and HSL are still the formats you reach for day to day, the breakdown of what each one encodes covers where each is genuinely the right tool — HSL is still perfectly good for "make this a bit lighter" on a single hue. It falls apart specifically when you compare across hues.
The catch: gamut
OKLCH can express colours that sRGB cannot display. Push chroma up and some hues fall outside the monitor's range, at which point something has to give — either the browser gamut-maps it or the values clip.
That is why the table above uses chroma 0.14 rather than something more saturated. It is comfortably inside sRGB for all twelve hues, so no clipping muddies the comparison. If you build a palette at high chroma you will find the achievable maximum varies by hue, and you are back to per-hue tuning — just at a much higher saturation than HSL forced on you.
The practical approach is to pick the lightness ramp first, then find the maximum chroma that stays in gamut across every hue in your set, and use that as the ceiling. The colour palette generator does this for you: it builds the 50–950 shade scale in OKLCH, checks WCAG contrast per step, and exports to CSS custom properties, Tailwind and Figma. If you only need to move a single value between notations, the colour converter handles HEX, RGB, HSL and HSV directly.
Run it yourself
No libraries, no dependencies. This is the exact script the tables came from — HSL to sRGB, OKLCH to sRGB via Oklab, then WCAG relative luminance on both.
import math
def hsl_to_srgb(h, s, l):
"""h in degrees, s and l in 0..1. Returns gamma-encoded sRGB in 0..1."""
c = (1 - abs(2 * l - 1)) * s
x = c * (1 - abs((h / 60) % 2 - 1))
m = l - c / 2
r, g, b = [
(c, x, 0), (x, c, 0), (0, c, x),
(0, x, c), (x, 0, c), (c, 0, x),
][int(h // 60) % 6]
return r + m, g + m, b + m
def oklch_to_srgb(L, C, h):
"""L in 0..1, C is chroma, h in degrees. Returns gamma-encoded sRGB."""
a = C * math.cos(math.radians(h))
b = C * math.sin(math.radians(h))
l_ = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
m_ = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
s_ = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3
lin = (
+4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_,
)
return tuple(gamma_encode(v) for v in lin)
def gamma_encode(v):
v = max(0.0, min(1.0, v))
return 12.92 * v if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
def relative_luminance(rgb):
"""WCAG 2.x relative luminance from gamma-encoded sRGB."""
def lin(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = (lin(max(0.0, min(1.0, c))) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_on_white(rgb):
return 1.05 / (relative_luminance(rgb) + 0.05)
def hex_of(rgb):
return '#' + ''.join(f'{round(max(0.0, min(1.0, c)) * 255):02x}' for c in rgb)
HUES = list(range(0, 360, 30))
for label, make in (
('HSL L=50% S=100%', lambda h: hsl_to_srgb(h, 1.0, 0.5)),
('OKLCH L=0.62 C=0.14', lambda h: oklch_to_srgb(0.62, 0.14, h)),
):
print(label)
lums = []
for h in HUES:
rgb = make(h)
lum = relative_luminance(rgb)
lums.append(lum)
print(f'{h:>4} {hex_of(rgb):>8} {lum:>9.4f} {contrast_on_white(rgb):>6.2f}')
print(f'spread {max(lums) / min(lums):.1f}x\n')
The gamma_encode clamp is doing gamut handling the crude way — anything outside sRGB gets pinned to the edge. Good enough to make the comparison honest at chroma 0.14, not good enough for a production palette, where you want proper gamut mapping that preserves hue while reducing chroma.
What to do with this
Stop using HSL lightness as a cross-hue token level. Within one hue it is fine. Across hues it is meaningless, and the 12.9× figure is what "meaningless" costs.
Define shade scales by OKLCH lightness, then set one chroma ceiling that stays in gamut for every hue you ship. One decision, uniform result.
Check contrast at the step, not at the colour. If your 500 level passes on white for the worst hue in the set, it passes for all of them — which is the entire benefit and the reason the ramp is worth rebuilding.
And if you are exporting brand colours anywhere beyond CSS, remember the same value ends up in <meta name="theme-color"> and in your social preview images, where a colour that quietly fails contrast is suddenly the first thing anyone sees — the meta tag checklist covers what belongs in that part of the head.