Color Spaces & Models

RGB, HSL, CIELAB, and OKLCH — what each is for and where each breaks.

A color space is a coordinate system for color: a repeatable way to turn a perception into numbers and back. Different spaces optimize for different things - driving hardware, intuitive editing, or matching human perception - and most day-to-day color frustration comes from using a space outside its design brief.

What is a color space?#

Because human vision is trichromatic, three numbers suffice to describe any color a display can show. A color space defines what those three axes mean and which real-world color each coordinate maps to. Two properties matter constantly in practice:

  • Device-dependent vs. absolute- “100% red” in raw RGB means whatever the panel's red sub-pixel emits; an absolute space like CIE XYZ names the same perceived color on every device.
  • Perceptually uniform or not - whether equal moves in coordinates produce equal-looking changes. (The next chapter is devoted to this.)

Device spaces: RGB and sRGB#

Displays mix red, green and blue light, so RGB triplets are the native language of screens. sRGB (standardized in 1996) pinned down exactly which red, green and blue - and a transfer function(“gamma”) that allocates more of the 0–255 code range to dark tones, where eyes are more sensitive. The web assumes sRGB unless told otherwise; a plain #3b82f6 is an sRGB value.

HSL: intuitive but misleading#

HSL rearranges sRGB into hue, saturation, and lightness - axes that finally sound human. The catch: HSL is a purely geometric transform of RGB, with no perceptual modelling. Its “lightness” treats fully saturated yellow and fully saturated blue as equally light (both L = 50%), while your eye sees the yellow as far brighter:

H 0°
H 60°
H 120°
H 180°
H 240°
H 300°
H 30°
H 90°
H 150°
H 210°
H 270°
H 330°
Six hues at identical HSL lightness (top, hsl(h 100% 50%)) versus six hues at identical OKLCH lightness (bottom, oklch(0.7 0.14 h)). The top row visibly jumps in brightness; the bottom row holds steady.

The same flaw makes HSL-generated palettes uneven: stepping L by 10% yields big visual jumps in some hues and barely visible ones in others. HSL is fine for quick, rough adjustments - just don't build a design system's lightness scale on it.

CIE spaces: XYZ and LAB#

In 1931 the CIE measured how average observers match colors and published CIE XYZ: the first absolute, device-independent space. XYZ can name every visible color, but its axes aren't perceptual. CIELAB (1976) reshaped XYZ so that distances roughly match perceived difference - introducing the L*, a*, b* axes and the ΔE color-difference metric. LAB was a huge step, but it distorts blue hues (gradients through blue bend purple) and its lightness still drifts with chroma.

OKLab (Björn Ottosson, 2020) is a numerically small fix with outsized practical impact: same shape as LAB, but fitted against better data so hue stays stable while lightness or chroma change. OKLCH is simply OKLab expressed in cylindrical coordinates - lightness, chroma, hue - which is the form designers actually want to edit.

OKLCH, coordinate by coordinate#

L - lightness (0 → 1)C - chromaH - hue angle (0–360°)oklch(0.65 0.19 250)black (L = 0) at the base, white (L = 1) at the top
The OKLCH cylinder. Every color is a point: height = lightness, distance from the axis = chroma, angle = hue. Lumea's 3D LCH Cylinder card plots your palette in exactly this space.
AxisRangeMeaning
L0 – 1Perceived lightness. 0 is black, 1 is white. Unlike HSL's L, equal steps look equal across all hues.
C0 – ~0.4Chroma: distance from gray. 0 is achromatic; the usable maximum depends on hue and lightness (and on the display's gamut).
H0 – 360°Hue angle. ~30° red, ~140° green, ~260° blue. Changing only H keeps lightness and chroma genuinely constant.

In CSS

styles.css
/* oklch(L C H [/ alpha]) - supported in all modern browsers */
.accent {
  color: oklch(0.65 0.19 250);          /* a mid blue */
}
.accent-hover {
  color: oklch(0.58 0.19 250);          /* same hue & chroma, darker */
}
.accent-subtle {
  color: oklch(0.65 0.05 250 / 80%);    /* desaturated, translucent */
}

Notice what the code demonstrates: a hover state is one number away from its base color, and the relationship is stated in perceptual terms. The equivalent hex pair (#4a8fe0#3577c7) hides that intent completely.

Choosing a space#

TaskUseWhy
Storing / transmitting web colorssRGB hex or rgb()Universal, compact, assumed default
Quick eyeball tweaksHSLFamiliar axes; fine when precision doesn't matter
Design-system palettes & scalesOKLCHEven steps, stable hues, CSS-native
Measuring color differenceOKLab / ΔEDistances approximate perception
PrintCMYK (via ICC profiles)Matches subtractive ink mixing

Further reading#