Skip to content

Switching to OKLCH - On to a new gamut

Every color in this portfolio is now oklch(). Hex, rgb(), rgba(), hsl(), hsla(), all gone. 13 design tokens, 11 dark mode overrides, SVG chart labels, OG image gradients, even the animated avatar ring.

Why

OKLCH fixes three problems that the old color spaces all share:

Perceptual uniformity. Equal L steps look equally bright, no matter the hue. oklch(0.5 ...) is visually halfway between black and white. HSL's lightness: 50% is all over the place. Yellow at hsl(60, 100%, 50%) is way brighter than blue at hsl(240, 100%, 50%).

Stable hue. In HSL, blue drifts toward purple as you change lightness (about 16° across a typical palette ramp). In OKLCH, hue stays put. You can generate an entire palette by varying L alone and the color family holds.

Independent chroma. Chroma in OKLCH is an absolute measure of colorfulness, separate from lightness. HSL saturation changes with lightness, so you can't keep vividness consistent across a scale. Same chroma percentage in OKLCH means equal vividness relative to what each hue can output.

What changed

LayerBeforeAfter
Design tokens (@theme)13 hex valuesoklch(L C H)
Dark mode (.dark)11 hardcoded hex overridesSame tokens, reversed L mapping
SVG chart labels#0f172a / #fffoklch(0.208 0.040 265.755) / oklch(1 0 0)
OG image generator6 inline hex + gradientsoklch() in gradient stops and fills
Avatar ring animationhsla(0deg, 86%, 59%, 1)oklch(0.632 0.216 25.967)

What you actually get

Contrast fixes become a one channel problem. With hex, fixing contrast means trial and error across R, G, and B. In OKLCH you adjust L only. Chroma barely touches contrast.

Dark mode is a flip. Reverse the palette mapping and the lightest step becomes the darkest. Perceptual uniformity means equal L steps work in both directions without hand tuning.

No hue drift in palettes. My secondary palette (slate) now has near-constant hue across all 11 steps, 257.2° to 257.4°. Well under the 10° drift threshold.

Beyond sRGB — P3 colors, no extra code

Hex, rgb(), and hsl() are all bound to the sRGB color space. They literally cannot express a color outside sRGB. Display P3 covers ~50% more colors, especially in cyans, reds, and purples.

OKLCH reaches P3 without any polyfill, media query, or fallback gymnastics. Push chroma past the sRGB ceiling and the browser renders P3 where supported, auto-clamping to the nearest sRGB color otherwise. Same oklch() value, progressive enhancement for free.

Your display:detecting…

Cyan (H=185°)

oklch(0.65 0.2 185)

P3-enhanced - auto-clamped on sRGB

oklch(0.65 0.09 185)

sRGB best-effort - max chroma at this hue and lightness

Red-Orange (H=25°)

oklch(0.55 0.28 25)

P3-enhanced - auto-clamped on sRGB

oklch(0.55 0.21 25)

sRGB best-effort - max chroma at this hue and lightness

Purple (H=310°)

oklch(0.5 0.35 310)

P3-enhanced - auto-clamped on sRGB

oklch(0.5 0.29 310)

sRGB best-effort - max chroma at this hue and lightness

Each pair shows the same P3-capable OKLCH value (top) alongside its sRGB-clamped equivalent (bottom). On a P3 display, the top swatches are visibly more vivid - a color you cannot express in hex, rgb(), or hsl(). On sRGB, both rows look identical because the browser automatically gamut-maps the top value down.

The demo uses three P3 colors — oklch() values whose chroma exceeds what sRGB can represent at that hue and lightness. No color(display-p3 ...) syntax. No @media (color-gamut: p3) wrapper. Just oklch().

References


Stay ahead of the curve in Web Development with Javascript Every Month Newsletter.

I will deliver a curated selection of articles, tutorials, and resources straight to your inbox once a month.

Read the Archives

Subscribe to JEM Newsletter

No spam, unsubscribe anytime.