I was building a multi-brand theme — same component library, three different brand colors — and kept having to manually adjust the light and dark variants of each scale. What worked perceptually for a blue brand looked slightly off for teal. The teal --color-subtle background read as too saturated. The border at the same lightness looked heavier. Nothing was broken in code. It just didn’t look right.
The issue was HSL. The lightness value in HSL is not perceptual — it’s geometric. It describes position in a mathematical cylinder, not how the human eye processes brightness. Two colors can share the same HSL lightness and look visually mismatched because our perception of brightness varies by hue. Teal at hsl(180, 60%, 40%) looks considerably lighter than a red-violet at hsl(300, 60%, 40%) — same L, very different reality.
When you build a design token system on HSL, you’re calibrating against a measurement that doesn’t match what gets rendered on screen. oklch does.
What oklch actually is
The name breaks down:
- ok — built on OKLab, a perceptual color space developed by Björn Ottosson specifically to fix the uniformity problems in older models
- L — lightness, 0 to 1, perceptually uniform:
0.5looks halfway between black and white regardless of hue - C — chroma (colorfulness), roughly 0 to 0.4 for sRGB colors, higher for wide gamut
- H — hue angle, 0–360
/* same purple, two syntaxes */
color: hsl(263, 70%, 45%);
color: oklch(0.47 0.26 283);
The H numbers aren’t equivalent — oklch hue angles are in a different perceptual space, so you can’t swap them directly. Translate values using oklch.com or the DevTools color picker, which shows oklch coordinates for any color.
The practical difference: in oklch, when you adjust L, the perceived hue stays stable. Shift a purple from L0.3 to L0.7 and you get a consistently lighter purple — not something that drifts toward blue or pink the way HSL does as you push lightness up. That drift is the thing that makes HSL scales feel inconsistent across hues.
The multi-brand problem, solved
Here’s the concrete version of the problem I was running into. In HSL, building a “subtle background” token across three brand colors:
/* These three do NOT look equally subtle */
--brand-subtle-blue: hsl(220, 60%, 95%);
--brand-subtle-teal: hsl(180, 60%, 95%);
--brand-subtle-violet: hsl(280, 60%, 95%);
Teal at L95% reads as noticeably more saturated and brighter than violet at the same values because cyan/teal hues have high inherent luminance in sRGB. You end up tweaking each one by hand — dropping the saturation on teal, nudging the lightness on violet — until they look equivalent. That manual calibration breaks every time you add a fourth brand color.
In oklch, fixing the lightness actually fixes the perception:
/* These do look equally subtle */
--brand-subtle-blue: oklch(0.96 0.02 250);
--brand-subtle-teal: oklch(0.96 0.02 195);
--brand-subtle-violet: oklch(0.96 0.02 295);
Same L, same C, just different H. The visual weight is consistent. No per-hue calibration needed.
This is what made me stop treating oklch as a nice-to-have and start treating it as the right default for token work.
Building a color scale in oklch
In HSL, generating a correct color scale requires manual tweaking per hue because of the perceptual inconsistency. In oklch, you can generate it mathematically.
:root {
--purple-50: oklch(0.97 0.02 264);
--purple-100: oklch(0.93 0.05 264);
--purple-200: oklch(0.86 0.09 264);
--purple-300: oklch(0.76 0.14 264);
--purple-400: oklch(0.64 0.19 264);
--purple-500: oklch(0.52 0.24 264);
--purple-600: oklch(0.42 0.22 264);
--purple-700: oklch(0.34 0.18 264);
--purple-800: oklch(0.26 0.13 264);
--purple-900: oklch(0.18 0.08 264);
--purple-950: oklch(0.12 0.04 264);
}
Lightness steps are uniform increments. Chroma tapers at the extremes — very light and very dark colors have less chromatic range available before values clip out of gamut, so the tapering is intentional, not accidental. A value like oklch(0.97 0.24 264) would be out of sRGB gamut and the browser would clip it to the nearest valid color, which is not what you want.
Now swap in a completely different hue:
:root {
--orange-500: oklch(0.52 0.24 30); /* same L, same C, different H */
--orange-200: oklch(0.86 0.09 30);
--orange-800: oklch(0.26 0.13 30);
}
The semantic token relationships — light text on 50, body on 600, subtle background on 100 — hold across both hues without manual adjustment. This is not possible in HSL without per-hue calibration.
Chroma and gamut clipping
The C value is where oklch gets genuinely nuanced, and it’s the thing most introductions gloss over.
Unlike HSL saturation — which is always 0–100% because it’s relative to the current gamut — oklch chroma is an absolute value. A chroma of 0.38 might be inside the P3 gamut but outside sRGB. The browser handles out-of-gamut values by clipping to the nearest in-gamut color, which is not always elegant.
Practical boundaries for consistent cross-display behavior:
| Range | Description |
|---|---|
0 – 0.10 | Neutrals, low-saturation tints |
0.10 – 0.20 | Mid-saturation — safe across displays |
0.20 – 0.32 | High saturation — stays in sRGB |
0.32 – 0.40 | Wide gamut territory — vivid on P3 screens, clips on sRGB |
0.40+ | Outside sRGB for most hues |
DevTools flags out-of-gamut colors with a warning icon in the color picker. That’s your fastest feedback loop when you’re calibrating a scale.
A subtle issue: the maximum in-gamut chroma varies by hue. Yellows and cyans can handle higher chroma at mid-lightness; blues and reds are more constrained. There’s no universal “safe maximum” — you have to check per hue.
Wide gamut: using P3 intentionally
Modern displays — every iPhone since 2016, every Mac since the P3 iMac, most high-end PC monitors — support the Display P3 color space. About 25–35% more colors than sRGB. oklch can express these colors directly, and CSS lets you use them without any special syntax:
/* This is already wide gamut if chroma is high enough */
color: oklch(0.6 0.35 30); /* vivid orange, outside sRGB, beautiful on P3 */
The browser on an sRGB display clips it. On a P3 display, it renders the wider color. The same value, two different results — and both are “correct” for their display.
For design system work, I’d use a layered approach:
.button-primary {
/* sRGB-safe */
background: oklch(0.52 0.26 264);
}
@media (color-gamut: p3) {
.button-primary {
/* More vivid on capable displays */
background: oklch(0.52 0.36 264);
}
}
The visual effect is subtle in screenshots but immediately apparent on an actual P3 screen. Colors feel more alive without looking garish — because they’re not being pushed to the edge of sRGB the way our “vivid” colors used to be.
color-mix() in oklch
color-mix() lets you blend two colors in CSS. The color space you mix in matters more than most people realize.
/* Mixing in sRGB — complementary colors produce a grey midpoint */
color: color-mix(in srgb, oklch(0.6 0.25 30), oklch(0.6 0.25 210));
/* Mixing in oklch — perceptually smooth, hue travels the short arc */
color: color-mix(in oklch, oklch(0.6 0.25 30), oklch(0.6 0.25 210));
Mix orange and blue in sRGB and the midpoint goes grey — RGB channels average out to flat. Mix in oklch and the midpoint is a vivid purple or green, depending on which arc of the hue wheel the interpolation travels (there are two: shorter and longer).
For design tokens, color-mix() in oklch is how you generate an entire palette from a single base color at runtime:
:root {
--brand: oklch(0.52 0.24 264);
/* Tints — toward white */
--brand-100: color-mix(in oklch, var(--brand), white 85%);
--brand-200: color-mix(in oklch, var(--brand), white 70%);
--brand-300: color-mix(in oklch, var(--brand), white 50%);
/* Shades — toward black */
--brand-700: color-mix(in oklch, var(--brand), black 30%);
--brand-800: color-mix(in oklch, var(--brand), black 50%);
--brand-900: color-mix(in oklch, var(--brand), black 70%);
}
Change --brand and the entire scale regenerates in the browser. This is the one-token theme.
One thing to know: mixing toward pure white desaturates because white has zero chroma in oklch. Very light steps will lose vibrancy. If you want saturated pastels at step 100, mix toward a light but chromatic color — like oklch(0.96 0.06 264) — instead of white.
@property and animating oklch
CSS custom properties are untyped strings by default. The browser can’t interpolate oklch(0.52 0.24 264) as a color — it just sees text. Animations on custom properties containing oklch values snap instead of transitioning.
@property fixes this by giving a custom property an explicit type:
@property --hue {
syntax: '<number>';
inherits: false;
initial-value: 264;
}
@property --chroma {
syntax: '<number>';
inherits: false;
initial-value: 0.24;
}
@property --lightness {
syntax: '<number>';
inherits: false;
initial-value: 0.52;
}
.element {
background: oklch(var(--lightness) var(--chroma) var(--hue));
transition:
--hue 0.4s cubic-bezier(0.22, 1, 0.36, 1),
--lightness 0.3s ease;
}
.element:hover {
--hue: 30;
--lightness: 0.62;
}
The transition animates through the oklch color space. Hue rotates around the wheel through colors that look intentional at every frame. Lightness shifts perceptually. No grey midpoint, no hue drift — compare this to a standard background-color transition between two HSL values and you’ll see immediately why the space matters.
This pattern is how I’d approach interactive theme switching, animated focus states, or any hover effect where the color shift needs to feel crafted rather than mechanical.
Semantic token architecture
The full picture: primitives defined in oklch, semantics built on top, color-mix() for derived values, @property for animation.
/* Layer 1: primitives */
:root {
--purple-200: oklch(0.86 0.09 264);
--purple-500: oklch(0.52 0.24 264);
--purple-800: oklch(0.26 0.13 264);
--neutral-50: oklch(0.97 0.005 264); /* slightly tinted neutral */
--neutral-200: oklch(0.88 0.005 264);
--neutral-900: oklch(0.14 0.005 264);
}
/* Layer 2: semantic */
:root {
--color-brand: var(--purple-500);
--color-brand-subtle: var(--purple-200);
--color-surface: var(--neutral-50);
--color-border: var(--neutral-200);
--color-text: var(--neutral-900);
}
/* Layer 3: dark mode — same semantics, different primitives */
@media (prefers-color-scheme: dark) {
:root {
--color-brand: oklch(0.64 0.19 264); /* one step lighter for dark bg */
--color-brand-subtle: var(--purple-800);
--color-surface: var(--neutral-900);
--color-border: oklch(0.22 0.005 264);
--color-text: var(--neutral-50);
}
}
Because oklch lightness is perceptual, the dark mode --color-brand at L0.64 reads as the same visual weight against a dark background as L0.52 against a light one. You’re not guessing — you’re adjusting by one lightness step and it works. In HSL, this kind of cross-mode consistency is heuristic. In oklch, it’s geometric.
The slightly-tinted neutrals (oklch(0.97 0.005 264) instead of oklch(0.97 0 0)) are a detail worth considering. Pure neutral greys have zero chroma and zero hue, which makes them feel cold and disconnected from the rest of the palette. A tiny chroma value — 0.005 — adds the barest hint of the brand hue and makes the overall palette feel more cohesive. Nobody notices it consciously. Everyone notices when it’s missing.
Browser support
oklch color values: Chrome 111, Firefox 113, Safari 15.4. All major browsers, no @supports needed for the values themselves.
color-mix() landed in the same browser versions. @property has been in Chrome since 85, Firefox since 128, Safari since 16.4 — also no longer requires a flag anywhere.
The gotcha is older browsers silently ignore oklch values — they don’t error, they just skip the declaration. Always include a fallback:
.element {
color: hsl(260, 70%, 50%); /* fallback */
color: oklch(0.52 0.24 264); /* modern browsers override */
}
The cascade handles this cleanly. The second color declaration wins in any browser that understands oklch; older browsers see it as invalid and stick with the hsl fallback.
Migrating an existing system
You don’t need to convert everything at once. The migration has a natural order:
Start with animation. Anywhere you’re animating color — hover states, focus rings, theme transitions — add @property and switch to oklch. This is isolated, immediately visible, and doesn’t touch tokens other components depend on.
Move the primitive scale. Convert your base color ramp. Run your design tokens through oklch.com or Huetone — both let you visualize and adjust a scale in oklch. Check the perceptual lightness consistency, adjust chroma at extremes, compare with your old scale until it matches.
Semantic tokens don’t change. They reference primitives by name. Swap the primitive values and the semantic layer stays intact.
The hardest part is not technical. It’s getting designers to stop specifying colors in hex and accept that oklch(0.52 0.24 264) is the canonical source of truth. Figma added native oklch support in 2024, which helps — you can now inspect colors in oklch directly. But the mental model shift from “a color is a hex code” to “a color is a point in perceptual space” takes longer than a sprint.
The practical payoff: dark mode that works without per-component manual checking. Consistent contrast ratios across your palette by construction. Color animations that look crafted. And a color scale you can extend to new hues without recalibrating from scratch.
That’s the actual argument for oklch — not the color theory, but that it makes a lot of tedious work disappear.