All posts

Engineering

Why I abandoned HEX and HSL for OKLCH in Tailwind v4

Engineering color for perceptual uniformity, accessibility, and the P3 gamut.

·5 min read

The problem with HSL

Its lightness number does not mean lightness.

HEX and RGB describe what a monitor should emit, not what a person sees. HSL looks like the fix, because it exposes a lightness value you can reason about. But that value is a lie: HSL lightness is computed from RGB channels, not from human perception.

The contrast roulette

In HSL, pure yellow at 50 percent lightness reads as nearly white, while pure blue at the same 50 percent reads as nearly black. Derive contrast from HSL lightness and you are guessing, with accessibility as the stake.

This site began as a project for a school, where a parent with low vision is not an edge case. Guessing at contrast was not acceptable, so the design system needed a color model whose numbers tell the truth.

Enter OKLCH

A color space built on measured perception.

OKLCH, from CSS Color Level 4, describes color as lightness, chroma, and hue in the Oklab space, which is fitted to how people actually perceive brightness. Its key property is perceptual uniformity: two colors with the same L value look equally bright, whatever their hue.

L: lightness

0 to 1. The same L value is the same perceived brightness for every hue, which makes contrast something you can calculate.

C: chroma

Color intensity from gray outward. Lowering it desaturates cleanly instead of drifting toward mud.

H: hue

The angle on the color wheel. Rotating it keeps lightness and intensity stable, so hue shifts stay smooth.

The comparison below shows five hues pinned to the same lightness in each model. The HSL row jumps between bright and dark; the OKLCH row holds steady.

Interactive comparison

HSL — 50% lightness

Non-uniform
YellowL: 50%
GreenL: 50%
RedL: 50%
CyanL: 50%
BlueL: 50%
HSL — perceived brightness
95%
72%
53%
79%
28%
vs

OKLCH — 0.6 lightness

Perceptually uniform
YellowL: 0.6
GreenL: 0.6
RedL: 0.6
CyanL: 0.6
BlueL: 0.6
OKLCH — perceived brightness
60%
60%
60%
60%
60%

HSL blue at 50% lightness appears 3.4× darker than HSL yellow at 50% lightness. In OKLCH, all five colors share identical perceived brightness.

Beyond sRGB

HEX cannot name the colors modern screens show.

There is a second argument. HEX and HSL are locked to the sRGB gamut, while most current phones and laptops render the wider Display P3 gamut. OKLCH can address those colors directly, and the browser falls back to sRGB on hardware that cannot show them.

35%

Legacy sRGB

Of the visible spectrum

+25%

Display P3

More colors than sRGB

95%+

Browser support

For oklch() in CSS

Implementation in Tailwind v4

Primitive ramps, semantic aliases, utilities.

Tailwind v4 is CSS-first: tokens are plain custom properties, and the @theme block turns them into utility classes with opacity modifiers included. This site defines a small set of OKLCH primitives, aliases them to semantic names, and exposes only the semantic layer to components. This is the actual token source of the page you are reading.

theme.csscss
/* theme.css: the single source of design values */
:root {
  /* Primitive ramps, all OKLCH */
  --paper-100: oklch(0.965 0.013 84);
  --ink-900: oklch(0.245 0.02 60);
  --green-700: oklch(0.4 0.075 152);
  --green-800: oklch(0.34 0.07 152);
  
  /* Semantic aliases */
  --background: var(--paper-100);
  --foreground: var(--ink-900);
  --primary: var(--green-700);
  --primary-strong: var(--green-800);
}

@theme inline {
  /* Tailwind v4 turns these into utilities,
     opacity modifiers included */
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
}

The discipline pays off in maintenance. Deriving a hover state means lowering L a step while keeping C and H, and the result stays on palette. Checking contrast means comparing two L values instead of eyeballing screenshots.


See the tokens

The complete OKLCH theme and the Tailwind v4 setup are in the public repository.

View repository