Chapters

Part 1 — Foundations · Chapter 4

Why HSL lies

hsl(60 100% 50%) and hsl(240 100% 50%) claim the same lightness — one emits thirteen times the light of the other. Where HSL's numbers actually come from, and why the engine must never do math in them.

Here's the most popular color trick on the web: pick a saturation and a lightness, rotate the hue, collect a palette. hsl(h 70% 55%) for eight hues — done. And because every swatch shares the same L, the palette is evenly lit. It says so right there in the notation.

45°90°135°180°225°270°315°
Eight hues from one recipe — hsl(h 70% 55%). The notation swears they all share a lightness of 55%. The grays are what your eye actually receives: they span L* 43 to 81 — from darker than chapter 3's halfway gray to lighter than its three-quarters gray.

Flip the switch. The grays are the light each swatch actually emits, and they run from L* 43 to L* 81 — from darker than chapter 3's halfway gray to lighter than its three-quarters gray. The palette that promised one lightness spans nearly forty L* points. Nothing subtle went wrong; the number called "lightness" just doesn't measure lightness.

Chapter 2 called HSL "RGB with the furniture rearranged," and filed its friendliness under dishonest with a pointer to this chapter. Time to pay that off — because HSL isn't slightly miscalibrated. Every one of its three channels is a lie about perception, all three for the same reason.

Turn one knob, watch two things move

The palette confounds hue and lightness, so isolate it. Pin S at 100% and L at 50%, turn only the hue, and measure what comes out of the screen:

Playgroundhsl() holds L at 50%. Does the lightness hold?
gray at L=50% (22%)180°360°0%50%100%hue — S and L pinned at 100% / 50%light emitted
hsl(60 100% 50%)its light, as gray
light emitted
92.8%
lightness (L*)
98

The dashed line is the light a 50%-lightness gray emits. The curve is what "constant 50% lightness" actually delivers as the hue turns: 7% of white's light at blue, 93% at yellow.

The "light emitted" readout is the relative luminance from the WCAG definition — the same measurement contrast checking runs on (chapter 8). It's built from things you already know: decode each channel through chapter 3's curve to linear light, then weight the channels by how much of white's light each primary carries — red 21%, green 72%, blue 7%.

Those weights are the punchline waiting to happen. Drag the slider from 60° to 240°: same S, same L, and the light emitted collapses from 93% of white to 7% — a factor of thirteen. In perceptual terms, hsl(60 100% 50%) sits at L* 98, practically white; hsl(240 100% 50%) sits at L* 30, a dark blue. This isn't an edge case someone found later — it's the CSS Color 4 spec's own example, in the section that defines hsl().

A hue rotation was never supposed to touch lightness. In HSL it swings lightness across most of its range.

What L actually computes

The lightness computation in CSS Color 4's conversion algorithm is one line:

L = (max(R, G, B) + min(R, G, B)) / 2

Take the largest channel and the smallest channel, average them. That's the whole formula — not a model of your eye — and it packs in two separate lies.

It runs on the wrong ladder. Chapter 3 split the world into values, light, and perception. R, G, B here are gamma-encoded values — positions on the value ladder. HSL does arithmetic on them and labels the result "lightness," a perception word.

And it's blind to which channels are lit. max is max whether the bright channel is green or blue; the formula never asks. But your eye asks: full green is 72% of white's light, full blue is 7%.

rgb(255 255 0)L = (255 + 0) / 2 → 50%
light emitted
93%
rgb(0 255 255)L = (255 + 0) / 2 → 50%
light emitted
79%
rgb(0 0 255)L = (255 + 0) / 2 → 50%
light emitted
7%
Identical arithmetic, three verdicts of "50% lightness." L averages the largest and smallest raw channel values and never asks which channels are lit — but in the light your screen emits, the green channel carries roughly ten times the blue channel's weight.

Notice what's missing from that formula: any measurement of a human. Lab and OKLCH (chapter 5) are fitted to experiments — people looking at colors and judging them. HSL is fitted to nothing. It's the RGB cube stood on its black corner, with cylindrical coordinates painted on: angle around the axis is "hue," distance from the axis is "saturation," height is "lightness." The names are aspirations, not measurements.

One more thing worth understanding: why HSL keeps earning your trust anyway. On grays, S is 0 and L is just the channel value — and chapter 3 showed the value ladder is a rough perceptual scale. Within a single hue, raising L does make the color lighter. So every quick test you run — a gray ramp, tints of one brand color — passes. The lie is locally true. It detonates the moment a formula crosses hues — which is exactly what palette generators, chart-color rotations, and "same L for every accent" token schemes do.

It was never designed to be true

None of this is a bug someone should have caught. HSV and HSL arrived together in the 1978 SIGGRAPH proceedings — HSV in Alvy Ray Smith's "Color Gamut Transform Pairs," HSL in Joblove and Greenberg's "Color Spaces for Computer Graphics" — as coordinate remaps of the RGB cube for frame-buffer paint programs. Smith's paper advertises its transform as non-trigonometric — cheap enough to run per pixel on 1970s hardware. The derivations are pure geometry — no perceptual measurement enters the transforms. For 1978, picking colors by eye on a framebuffer, that was the right trade. The mistake is ours: we kept doing arithmetic on its numbers for five decades after the hardware constraint died.

Today's spec is refreshingly blunt about it. CSS Color 4 — the document that defines hsl() — states: "A disadvantage of HSL over OkLCh is that hue manipulation changes the visual lightness, and that hues are not evenly spaced apart." The format's own specification tells you not to trust the knobs.

The other two knobs lie the same way

Same cube geometry, same missing human — hue and saturation fib identically.

Equal hue angles are not equal perceptual steps. The spec's own example pair:

Δ30° — near twins
220°250°
Δ30° — different colors
50°80°
Two pairs, both exactly 30° of hue apart at the same S and L. Around blue, 30° barely registers; between yellow and green it crosses a category boundary. Equal hue steps are not equal perceptual steps — this pair of examples is straight from the CSS Color 4 spec.

And S = 100% doesn't mean vivid. Saturation measures distance from the gray axis relative to the most the cube can hold at that lightness — a ratio of available geometry. Near white the cube narrows to a point, so "100% saturated" can describe a color one step from gray:

lightness (the knob)
50%
saturation claims
100%
measured chroma
0.31
hsl(250 100% 50%)
Every position on this slider reports S = 100%. Measured vividness (OKLCH chroma, chapter 5) collapses from 0.31 to 0.03. S doesn't measure how colorful a color looks — it measures how close the color sits to the edge of what its lightness allows, and near white that edge is almost on the gray axis.

So the full indictment reads: L isn't lightness, S isn't vividness, and equal hue steps aren't equal steps. Every channel of hsl() misreports the thing it's named after.

What an honest channel looks like

This chapter's job is demolition, not replacement — chapter 5 builds the successor properly. But you've earned one glimpse of the difference between a channel that's geometry and a channel that's a measurement:

hsl(h 100% 50%)
oklch(0.70 0.10 h)
Same sweep, two spaces: hue 0–360°, lightness channel held. As gray twins, the HSL strip is a mountain range; the OKLCH strip is a nearly flat band. Not perfectly flat — perceptual lightness is not a light meter, and chapter 5 explains the difference — but it keeps its promise.

OKLCH keeps HSL's knobs — hue, colorfulness, lightness, the right idea — but derives them from measured perception instead of cube geometry, so constant L actually holds. How it pulls that off, and what it costs, is chapter 5's whole subject. For now the comparison does its one job here: the lie is not inevitable. It's a property of HSL, not of color.

The decision this unlocks

Here's the Part 2 decision this chapter settles: the engine never generates or adjusts colors in HSL. Concretely, none of these operations may run in HSL coordinates:

  • Building a ramp by stepping L (chapter 11) — the steps would be even in cube geometry and uneven to every user.
  • Deriving hover/pressed states by nudging L (chapter 18) — the same nudge is invisible on one hue and drastic on another.
  • Rotating hue for categorical palettes (chapter 19) — that's the opening demo.
  • "Same L across accents" as a consistency guarantee — L guarantees nothing about how light the colors look.

HSL stays acceptable as an input notation a user might type; the moment math runs on the channels, it has to run in a space whose channels mean what they say. Which space earns that job is chapter 5; how to move between colors safely is chapter 7.

Before you move on

Further reading