sRGB/HSL → OKLCH: why v4 switched
CSS historically used sRGB (#hex, rgb()) and
HSL (hsl()). Both are non-perceptual: a "50% lightness"
yellow in HSL is glaringly brighter than a "50% lightness" blue — equal
numbers, unequal eyes. OKLCH is built on the
OKLab model, which is calibrated to human vision. Equal lightness steps
look equal across every hue. Tailwind v4 made OKLCH the default so its
-50…-950 ramps are visually consistent for every color.
| axis | range | what it controls |
|---|---|---|
| L — Lightness | 0–1 (or 0%–100%) | perceived brightness. 0=black, 1=white. Calibrated to vision, not to physics. |
| C — Chroma | 0–~0.4 | colorfulness / saturation. 0=gray, higher=vivid. Capped by the display gamut — very high C may clip on sRGB screens. |
| H — Hue | 0–360 | the color wheel angle (0=red, 90=yellow-green, 180=cyan, 270=blue, 360=red again). |
| / alpha | 0–1 (or %) | opacity. Tailwind emits color-mix(in oklab, … / alpha) for /40 modifiers. |
1 · OKLCH explorer — drag L / C / H, watch the swatch + live rgb conversion
Each slider drives one axis. The big swatch is set to
oklch(L C H) directly. The browser resolves it and we read
back getComputedStyle to show the live sRGB conversion —
proof the browser parses and applies OKLCH.
oklch(0.7 0.15 250)
→ rgb(—)
Click blue 250 then red 25: same L and C, only H changes. Notice the swatch brightness stays constant — that's perceptual uniformity. In HSL the same hue-swap would make red look brighter than blue.
2 · perceptual uniformity — same lightness, every hue
Both ramps below sweep the full hue wheel in equal steps with FIXED lightness & saturation. The OKLCH ramp should look evenly bright across every hue. The HSL ramp (same fixed lightness & saturation) looks uneven: yellow glows, blue stays muddy. Equal numbers, unequal eyes — the flaw OKLCH fixes.
OKLCH — L=0.70, C=0.13, H sweeps 0°→360°
HSL — L=70%, S=75%, H sweeps 0°→360°
3 · same hue, different color spaces — sRGB vs OKLCH gradient
Gradients between two colors of very different hue pass through a "gray
dead zone" in sRGB (the midpoint desaturates). OKLCH interpolation keeps
chroma up, so the transition stays vivid. The CSS engine uses
in oklch as the interpolation color space.
sRGB (default) — red → blue, muddy midpoint
OKLCH interpolation — red → blue, vivid throughout
4 · Tailwind v4 default palette — every -500 is OKLCH
These are the actual --color-*-500 theme values shipped in
Tailwind v4 (verified against tailwindcss.com/docs/colors, v4.3). The
swatch is painted straight from the OKLCH value.
| token | OKLCH value | swatch | computed rgb (live) |
|---|---|---|---|
red-500 | oklch(0.637 0.237 25.331) | — | |
amber-500 | oklch(0.769 0.188 70.080) | — | |
green-500 | oklch(0.723 0.219 149.579) | — | |
cyan-500 | oklch(0.715 0.143 215.221) | — | |
blue-500 | oklch(0.623 0.214 259.815) | — | |
violet-500 | oklch(0.606 0.250 292.717) | — | |
fuchsia-500 | oklch(0.667 0.295 322.150) | — | |
pink-500 | oklch(0.656 0.241 354.308) | — | |
gray-500 | oklch(0.551 0.027 264.364) | — |
Note the hue axis: red≈25, amber≈70, green≈150, cyan≈215, blue≈260,
violet≈293, pink≈354. The lightness of green-500 (0.723)
and cyan-500 (0.715) is nearly identical — so they look
equally bright side-by-side, by design.
5 · gold-check — @theme OKLCH token applied & resolved
The swatch below uses class="bg-brand". The brand
color is defined in the page's <style type="text/tailwindcss">
block as --color-brand: oklch(0.7 0.15 250). The gold-check
reads getComputedStyle(swatch).backgroundColor and confirms
the CDN compiled the OKLCH token into a real, opaque color — printing the
live rgb conversion as proof.
—
intent → pattern
| intent | pattern | why |
|---|---|---|
| define a custom OKLCH color | @theme { --color-brand: oklch(0.7 0.15 250); } |
enables bg-brand, text-brand, border-brand — perceptually uniform |
| 50% opacity tint | bg-brand/50 |
v4 emits color-mix(in oklab, var(--color-brand) 50%, transparent) — perceptually correct alpha |
| override a default ramp | --color-gray-500: oklch(0.551 0.027 264.364) |
redefine any --color-*-* token; utilities update automatically |
| vivid gradient (no dead zone) | bg-linear-to-r from-red-500 to-blue-500 |
v4 stops are OKLCH values → interpolation stays saturated |
| disable a color family | --color-lime-*: initial; |
removes the --color-lime-* vars from output entirely |
| arbitrary OKLCH value | bg-[oklch(0.7_0.15_250)] |
one-off color without touching @theme (underscores = spaces) |