WebCraft

Note #02 ·

Why a color-wheel palette comes out muddy

Rotate a hue in HSL and the lightness wanders off with it. Do the same in OKLCH and a whole palette falls out of one custom property.

The textbook recipe for a palette is a few hue rotations. Take one color, turn the hue 180 degrees for its complement, 30 either way for its neighbors, 120 and 240 for a triad. In HSL the recipe is one number per color, and the result tends to look wrong in a way that is hard to name. One swatch glows, another sinks, and the set reads as a bag of colors rather than a family.

The cause is the L in HSL. It is called lightness, but it measures something the eye does not. hsl(60 100% 50%) is a pure yellow and hsl(240 100% 50%) a pure blue, and both claim 50% lightness. Put them side by side and the yellow is nearly white while the blue is nearly black. Measured in OKLCH, which was built to follow perception, the yellow's lightness is 0.97 and the blue's is 0.45.

The two rows below are the same six hues, 60 degrees apart. The top row holds saturation and lightness fixed in HSL, the bottom row holds lightness and chroma fixed in OKLCH. Turn the hue, then switch the color off: with only the grey left, the HSL row jumps from light to dark and the OKLCH row stays level.

hsl, 85% 55%
oklch, 0.72 0.13

Switching the color off uses filter: grayscale(1), which is a rough stand-in for perceived lightness rather than a measurement of it. It is close enough to show the problem.

What the three OKLCH numbers mean

oklch() takes a lightness from 0 to 1, a chroma, and a hue angle. Lightness is scaled so that equal steps look like equal steps. Chroma is how far the color sits from grey: 0 is grey, and in sRGB the most vivid colors reach a little over 0.3. Hue is an angle, as in HSL, though the angles do not line up with HSL's, so a 25 in one is not a 25 in the other.

Holding L and C still while turning H is what the wheel recipe always assumed it was doing. In OKLCH it is close to true, so the rotations land on colors of the same weight.

A palette from one custom property

Relative color syntax lets CSS do the rotations. oklch(from var(--brand) l c h) takes the brand color apart into its three channels, and each channel can be replaced or computed. The color can start as anything, a hex value included.

:root { --brand: #3d5afe; }

.palette {
  --analog-1: oklch(
    from var(--brand) l c calc(h - 30));
  --analog-2: oklch(
    from var(--brand) l c calc(h + 30));
  --complement: oklch(
    from var(--brand) l c calc(h + 180));
  --triad-1: oklch(
    from var(--brand) l c calc(h + 120));
  --triad-2: oklch(
    from var(--brand) l c calc(h + 240));
}

Hue wraps on its own, so h + 240 on a hue of 300 lands at 180 with no modulo. Change --brand and every color in the set follows, in every theme that reads it.

Tints, shades and greys

An interface is built from far more tints and shades than harmonies. These come from setting the lightness outright and pulling the chroma back, since a very light or very dark color cannot carry much of it.

.palette {
  --tint: oklch(
    from var(--brand) 0.95 calc(c * 0.3) h);
  --shade: oklch(
    from var(--brand) 0.32 calc(c * 0.7) h);
  /* grey with a trace of the brand in it */
  --grey: oklch(from var(--brand) 0.6 0.02 h);
}

Set or multiply, and be careful about adding. Lightness and chroma clamp without a word, so calc(l + 0.3) on a token that is already light pins at white, and a palette built on additions works until someone feeds it a color near the edge.

A neutral that carries two hundredths of chroma in the brand's hue sits with the brand. A neutral taken from a generic grey scale tends to look slightly off against it, and nobody can say why.

Where OKLCH still lies

An sRGB screen cannot show every OKLCH color. A vivid blue at a given lightness fits, while a yellow-green at the same lightness and chroma falls outside what the screen can show, so the browser has to bring it back in range, and the color you see is not the one your numbers describe. That is why a triad built at full chroma can come out with one member gone khaki.

Keep chroma moderate, below about 0.15, when you rotate across the whole wheel, since far more of the wheel fits at that level. Then judge the result on screen rather than by the numbers. The WebCraft palette does both: it gives each rotated hue back the lightness its own hue can carry, and reads every swatch's contrast off the rendered color, not off the formula.

Text on top of it

A harmony tells you which colors are related. It says nothing about which ones you can put text on, which matters more once the palette meets an interface. Even lightness helps here as well. In OKLCH, swatches with the same L take the same text color far more often than HSL swatches do, so one rule can cover a whole row. Check the contrast anyway; the palette page labels every swatch with its WCAG 2 ratio and its APCA reading.

Relative color syntax works in current Chrome, Edge, Firefox and Safari. For older engines, declare a plain color first and the derived one second. An engine that cannot parse the second declaration drops it and keeps the first, so the page stays colored, just less varied.

Questions

<details name>

Why do HSL color palettes look uneven?

HSL lightness is a formula over the red, green and blue channels, not a measure of how light a color looks. A yellow and a blue at the same HSL lightness differ hugely to the eye, so rotating the hue changes the apparent weight of every color in the set.

What is the difference between HSL and OKLCH?

Both describe a color with a lightness, a colorfulness and a hue angle. OKLCH is built on a perceptual model, so equal lightness looks equally light across hues. HSL is a reshuffle of sRGB values and makes no such promise.

How do I get the complementary color in CSS?

With relative color syntax: oklch(from var(--brand) l c calc(h + 180)). It keeps the lightness and chroma of the source color and turns the hue halfway round the wheel.

Can CSS generate a whole color palette from one variable?

Yes. With relative color syntax each color is written as a calculation on one custom property, so analogous, complementary and triad colors, tints, shades and tinted greys all follow when that property changes.

Why does an OKLCH color look different from its numbers?

Its chroma is more than an sRGB screen can show at that lightness and hue, so the browser brings it back into range and the visible color shifts. Lower the chroma, or check the result on screen.