Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
Explanation

Why OKLCH?

Every hue looks evenly matched at each stop, so you can swap families without rechecking contrast.

Updated View as Markdown
Try it / Same lightness?
Show

HSL, lightness 50%

L* 53
L* 67
L* 97
L* 90
L* 88
L* 88
L* 91
L* 55
L* 32
L* 41
L* 60
L* 55

ColorTokensKit, stop _350

L* 72
L* 72
L* 72
L* 72
L* 72
L* 72
L* 72
L* 72
L* 72
L* 72
L* 72
L* 72

Every HSL swatch says 50% lightness, yet the lightness your eyes see (L*) runs from 32 to 97. The ColorTokensKit row holds one L* for every hue. Switch to “Lightness only” to see it.

These are twelve hues at HSL lightness 50%, and the same hues at our _350. Switch to "Lightness only" to take the hue away. The HSL row turns into grays from dark to nearly white, because HSL ignores how much brighter green looks than blue. Ours turns into one gray.

That's why we built the palette in OKLCH: every hue can share one lightness at each stop, so _600 is 4.68:1 on white in every hue, and you can swap families without checking contrast again. How did we choose and build these colors? has the short version.

How OKLCH describes a color#

OKLCH gives a color a lightness (L), a chroma (C, how far it is from gray) and a hue (H, an angle around the wheel). Björn Ottosson designed it in 2020 so that equal steps in the numbers look like equal steps in color. CSS writes the same numbers as oklch().

Why hue and chroma come from OKLCH#

swift
Color.proBlue._50.toLCH().h    // 258°
Color.proBlue._600.toLCH().h   // 272°, like every darker stop
#eff6ff
proBlue._50
#8fc0fe
proBlue._300
#4176b8
proBlue._600

Every proBlue stop sits at OKLCH hue 250°, and they read as one blue from light to dark. Measured in CIELab LCH, the same stops turn 14°. So a blue built at one LCH hue drifts toward purple as it gets lighter, while OKLCH holds it steady, and we take hue and chroma from OKLCH.

Why lightness comes from CIELab L*#

swift
let coral = OKLCHColor(l: 0.57, c: 0.08, h: 40)   // #A06753, L* 49.2, 4.61:1 on white
let teal = OKLCHColor(l: 0.57, c: 0.08, h: 190)   // #338682, L* 51.2, 4.29:1 on white
#a06753
coral
#338682
teal

They share an OKLCH lightness and chroma, but only the coral passes WCAG AA for text on white. WCAG contrast depends only on luminance, and OKLCH's L only comes close to it. CIELab's L* tracks luminance exactly, so we hold L* equal at each stop and use OKLCH for chroma and hue.

Trade-offs and limits#

Equal lightness costs some color: a yellow as dark as a blue reads as olive. What does equal lightness cost? covers each cost, and fitting colors into Display P3 covers colors that screens can't show.

Sources#

See also

  • .contrastRatio(to:method:) Check WCAG 2 and APCA contrast in Swift, and keep your color pairs passing with a unit test.
  • How gradients blend and ease Why plain gradients turn gray, how each blend travels between colors, and where easing puts them.
  • Replacing your app colors Move backgrounds, text, outlines, states, gradients and charts to tokens, one screen at a time.
  • .toOKLCH() Read a color's lightness, chroma and hue the way the palette sees them, to compare or adjust it.