OKLCH and Modern CSS Colors — Why Your Palette Looks Off in Some Browsers
RGB and HSL were built for CRT monitors. Here’s why OKLCH fixes perceptual uniformity, how color-mix() changes gradient blending, and what relative color syntax unlocks without a preprocessor.
You pick a blue for your primary button. It looks right in your browser. You open staging on a newer MacBook Pro and it looks… fine — just a little flat. Your designer sends a screenshot from their iPhone and the same button looks noticeably more vivid. Same hex code. Different display.
That’s the P3 gap. And it’s actually the least interesting part of why the CSS color model you’ve been using is failing you. The bigger problem is the math behind HSL.
The sRGB ceiling
Every hex color and rgb() value you’ve ever written lives in the sRGB color space — a standard built in 1996 for CRT monitors. sRGB covers roughly 35% of the colors visible to the human eye. The P3 color space, used by every iPhone since 2017 and most MacBooks since 2016, covers around 45%.
When you write #3b82f6, that color is capped at sRGB. On a P3 display, the browser could theoretically render a more saturated blue — but your CSS isn’t asking for it. You’re leaving display capability on the table by default.
To access P3 colors directly you’d use color(display-p3 0.2 0.4 0.9). That works. But it’s not why OKLCH matters. The bigger failure is in HSL’s core assumption.
HSL’s lightness is a lie
HSL was a genuine improvement over hex. Hue, Saturation, Lightness — conceptually sensible, editable by humans. The problem: the “L” doesn’t mean what you think it means.
Put yellow at hsl(60, 100%, 50%) and blue at hsl(240, 100%, 50%) side by side. Both are at exactly 50% lightness.
.yellow { background: hsl(60, 100%, 50%); }
.blue { background: hsl(240, 100%, 50%); }
Yellow looks almost white. Blue looks mid-dark. They’re nowhere near the same perceived brightness. This is HSL’s perceptual non-uniformity — the L axis doesn’t map to how human vision processes brightness.
This creates a real maintenance problem when building color scales. If you generate a 9-step scale by stepping L values in HSL, your yellows and cyans look blown out while your blues and purples look muddy. You end up tweaking values by hand, which defeats the entire point of having a systematic approach.
OKLCH: built for perception
OKLCH was designed by Björn Ottosson in 2020. It builds on CIELAB (a perceptual color space from the 1970s) but corrects its known hue shifts — particularly the purple/blue problem where adjusting chroma would visibly shift the perceived hue.
The three channels:
- L (Lightness) — 0 to 1, perceptually uniform. L 0.5 in yellow looks the same brightness as L 0.5 in blue. Actually.
- C (Chroma) — starts at 0 (grey), increases to roughly 0.3–0.4 for highly saturated colors. No fixed maximum — the upper limit varies by hue and target gamut.
- H(色相) — 0 to 360 degrees, roughly comparable to HSL hue.
Here’s Tailwind blue-500 in both systems:
/* sRGB hex */
background: #3b82f6;
/* OKLCH equivalent — same color, different notation */
background: oklch(0.623 0.214 259.1);
/* Push chroma past sRGB — more vivid blue on P3 displays, clips gracefully on sRGB */
background: oklch(0.623 0.27 259.1);
That last value falls outside the sRGB gamut. Browsers on P3-capable displays render the more vivid version; older browsers clip to the nearest sRGB equivalent. Progressive enhancement with zero effort.
color-mix() and why the color space changes blending
color-mix() shipped in Chrome 111, Firefox 113, and Safari 16.2. It mixes two colors at a ratio — simple enough. But the color space you specify changes the result significantly.
/* Mixes through sRGB — midpoint is a washed-out brown-grey */
background: color-mix(in srgb, oklch(0.7 0.2 30), oklch(0.7 0.2 270));
/* Mixes through the OKLCH color wheel — midpoint is a vivid purple */
background: color-mix(in oklch, oklch(0.7 0.2 30), oklch(0.7 0.2 270));
When you mix orange and blue in sRGB, you’re averaging raw channel values. The midpoint passes through a desaturated brown-grey. In OKLCH, you’re interpolating along the color wheel — you land on a vibrant purple. Which is correct depends on intent, but for UI transitions, gradients, and hover states, OKLCH interpolation is almost always what you want.
A practical use: generating a disabled state without hardcoding a second hex value.
.button--disabled {
background: color-mix(in oklch, var(--color-primary) 40%, white);
cursor: not-allowed;
}
Relative color syntax
Relative color syntax (Chrome 119+, Firefox 128+, Safari 16.4+) lets you derive a new color by modifying individual channels of an existing one:
:root {
--brand: oklch(0.623 0.214 259.1);
}
.button:hover {
/* Lighten by 8% lightness units */
background: oklch(from var(--brand) calc(l + 0.08) c h);
}
.button:active {
/* Darken and reduce chroma slightly */
background: oklch(from var(--brand) calc(l - 0.08) calc(c - 0.02) h);
}
.button--muted {
/* Drop chroma to near-zero: perceptual grey at the same lightness */
background: oklch(from var(--brand) l 0.03 h);
}
This replaces Sass’s lighten(), darken(),并且 desaturate() with native CSS — no preprocessor, no build step, no parallel hex values to keep in sync. And because you’re in OKLCH, “lightening” actually looks lighter across every hue in your palette, not just some of them.
One real limitation: complex channel constraints (like clamping chroma to stay in-gamut) require verbose calc() chains that get messy fast. For simple lightness and chroma shifts, the syntax is clean. For anything more sophisticated, computing values at build time and generating CSS custom properties is more maintainable.
Browser support in 2026
| 特征 | 铬合金 | Firefox | Safari |
|---|---|---|---|
oklch() | 111+ | 113+ | 15.4+ |
color-mix() | 111+ | 113+ | 16.2+ |
| Relative color syntax | 119+ | 128+ | 16.4+ |
color(display-p3) | 111+ | 113+ | 10+ |
Global support for oklch() is above 90% as of mid-2026. The hold-outs are older Chromium-based enterprise browsers and anything still running Firefox below 113. If those are in your user base, a two-line fallback is all you need:
.button {
background: #3b82f6; /* sRGB fallback */
}
@supports (background: oklch(0 0 0)) {
.button {
background: oklch(0.623 0.214 259.1);
}
}
In practice: if your analytics confirm Chrome 111+, Firefox 113+, and Safari 15.4+, you can skip the fallback entirely. The @supports wrapper is for peace of mind on internal tooling where you don’t control browser versions.
Converting your existing palette
The friction point with OKLCH adoption is getting from your current hex values to OKLCH coordinates and understanding what the channels mean for each color. The Unified Color Space Converter handles the conversion in both directions — paste a hex or HSL value, get OKLCH out, then start adjusting L and C from a real reference point. It supports HSL, RGB, Lab, LCH, HSV, and OKLCH in one place, which is useful when you’re working from a Figma file that gives you hex and need to land on OKLCH values you can reason about.
How to actually migrate
You don’t have to rewrite everything at once. A practical sequence:
- Start with interactive colors — your primary, secondary, and destructive tokens. These are the ones you’re already generating hover, active, and disabled variants from. OKLCH + relative color syntax replaces that logic with native CSS immediately.
- Leave static colors in hex for now — text, backgrounds, borders. They’re not being manipulated programmatically, so there’s no payoff to converting them yet.
- Build the next new system from scratch in OKLCH — that’s where the perceptual uniformity wins become structural. Your neutral scale’s step 500 will actually sit at the visual midpoint, and your saturated shades will be consistent across all hues without manual tweaking.
The goal isn’t OKLCH for its own sake. It’s having a color system where the math matches what your eyes see — so you stop compensating by hand every time you need a slightly lighter blue.
