OKLCH gives you perceptual uniformity, so a 5% lightness change reads as a 5% lightness change at any hue
One base color generates a full ramp via L stops (10/20/35/50/65/80/92), no hand-tweaking
Auto-contrast text picks black or white from a single L threshold, no contrast plugin needed
color-mix() in OKLCH blends hover/active states without sliding through gray mud
display-p3 lime is 18% more saturated than sRGB lime, and the @media fallback is two lines
I rebuilt my color tokens in OKLCH last month. Same brand, same lime, same dark UI, but every hover state stopped looking dirty. Here are the 5 patterns I kept.
Why OKLCH beats HSL for tokens
HSL lies. A 50% lightness blue and a 50% lightness yellow look nothing alike. The yellow is bright, the blue is muddy, and your dark mode pairings shift hue every time you tweak a lightness value. I had a "neutral" gray ramp in HSL that drifted purple at the top and green at the bottom. I never noticed until I put it next to a real neutral.
OKLCH is perceptually uniform. The L channel is actual perceived lightness, calibrated against human vision. So oklch(50% 0.15 250) and oklch(50% 0.15 60) look like the same brightness, just different hues. That sounds nerdy until you build a color ramp. Then it becomes the thing that makes the ramp work without 6 hours of nudging.
The C channel is chroma, which is colorfulness independent of lightness. The H channel is hue in degrees, same as HSL. The trick is that C has different ceilings at different L values, and those ceilings are higher in P3 displays than in sRGB. So OKLCH also gives you wide-gamut access for free if you want it.
If you want the deeper context on why dark UIs need this, see The 4-Tier Dark Mode Color System I Use on Every Project. It pairs well with this post.
Pattern 1: One base, a full ramp
Pick a single OKLCH value as the brand anchor. For RAXXO that's the lime, around oklch(95% 0.22 110). Then I generate the ramp by holding C and H constant and stepping L:
:root {
--brand-h: 110;
--brand-c: 0.22;
--lime-50: oklch(98% var(--brand-c) var(--brand-h));
--lime-100: oklch(95% var(--brand-c) var(--brand-h));
--lime-200: oklch(88% var(--brand-c) var(--brand-h));
--lime-300: oklch(80% var(--brand-c) var(--brand-h));
--lime-400: oklch(70% calc(var(--brand-c) * 0.95) var(--brand-h));
--lime-500: oklch(60% calc(var(--brand-c) * 0.85) var(--brand-h));
--lime-600: oklch(50% calc(var(--brand-c) * 0.7) var(--brand-h));
--lime-700: oklch(40% calc(var(--brand-c) * 0.55) var(--brand-h));
--lime-800: oklch(30% calc(var(--brand-c) * 0.4) var(--brand-h));
--lime-900: oklch(20% calc(var(--brand-c) * 0.25) var(--brand-h));
}
I taper C as L drops because dark colors physically can't hold as much chroma. Force it and you get clipping (the browser snaps to the nearest in-gamut value, which usually looks fine but not always). Tapering keeps the ramp predictable.
This replaced my old 11-step Tailwind-style ramp where I had hand-picked every hex. The new one took 4 minutes. The old one took an afternoon every time I added a new accent color.
Pattern 2: Auto-contrast text from one L threshold
In HSL you needed a getContrast() function and a wcag-contrast package. In OKLCH the L channel already is contrast, so:
.button {
background: var(--bg);
color: oklch(from var(--bg) l > 65% ? 10% 0 0 : 95% 0 0);
}
Wait, that syntax is wishful. The real one uses light-dark() or a custom prop trick. Here's what actually ships in 2026 browsers:
.chip {
--bg: var(--lime-300);
--bg-l: clamp(0, calc((l(var(--bg)) - 65) * 1000), 1);
background: var(--bg);
color: oklch(from var(--bg) calc(0.1 + (1 - var(--bg-l)) * 0.8) 0 0);
}
Even simpler: I keep two text colors, dark-on-light and light-on-dark, and pick with a JS function once at runtime when the token is generated. No per-component math.
const pickText = (bg) => {
const l = parseFloat(bg.match(/oklch\(([\d.]+)/)[1]);
return l > 65 ? "oklch(15% 0 0)" : "oklch(96% 0 0)";
};
The L threshold of 65 is the sweet spot for me. Anything brighter than that needs dark text, anything darker needs light. WCAG AA passes at every step of the ramp. I tested it on the lime, on a magenta variant, and on neutral gray. All three behaved the same way, which is exactly what perceptual uniformity buys you.
For more on the dark UI properties this builds on, see The 8 CSS Properties That Make Dark UIs Feel Premium.
Pattern 3: State colors that don't shift hue
The hover state bug I fixed twice in HSL: button is brand blue, hover is "5% darker," but darker in HSL pulls the hue toward green or violet. Now the hover looks slightly off. Designers blame the developer, developer blames the design system, no one blames HSL.
In OKLCH you change L, hue and chroma stay locked. So:
.button {
background: var(--lime-500);
transition: background 120ms ease;
}
.button:hover { background: oklch(from var(--lime-500) calc(l - 5%) c h); }
.button:active { background: oklch(from var(--lime-500) calc(l - 10%) c h); }
.button:disabled { background: oklch(from var(--lime-500) l calc(c * 0.3) h); }
The oklch(from ...) relative syntax is the magic. It reads back the L, C, H of the input and lets you adjust each channel. Hover stays the same color, just dimmer. Active stays the same color, dimmer still. Disabled keeps the lightness but kills the chroma, which gives you that desaturated "this is off" look without picking a separate gray.
I no longer keep hover-bg, active-bg, disabled-bg tokens for every interactive color. One base, three derivatives, all from the same line of CSS. My token file dropped 40% in size.
Pattern 4: P3 wide-gamut without breaking sRGB
OKLCH defines colors in a perceptual space that exceeds sRGB. If your display supports DCI-P3 (every Apple device since 2015, most modern Android, Pro displays on Windows), you can show colors that don't exist in the old gamut. RAXXO lime in sRGB is #e3fc02. In P3 it's about 18% more saturated. Real measurable difference, especially next to white space.
The pattern is two declarations, one fallback:
.brand-accent {
color: #e3fc02; /* sRGB fallback */
color: oklch(95% 0.22 110); /* P3 if supported */
}
@supports (color: oklch(95% 0.22 110)) {
.brand-accent { color: oklch(95% 0.22 110); }
}
Browsers that don't grok OKLCH read the hex and stop. Browsers that do grok it overwrite with the P3 value and render at maximum saturation on capable displays. Zero JS, zero polyfill.
The honest catch: Safari before 16.4 had OKLCH bugs where parsing succeeded but rendering used the wrong working space. I still keep the hex fallback first because of legacy iOS holdouts. If you target only modern browsers (Chrome 111+, Safari 16.4+, Firefox 113+), drop the hex.
Pattern 5: color-mix() gradients that don't go gray
Linear gradients between two colors in sRGB pass through a desaturated middle. Blue to yellow goes through gray. Red to green goes through brown. It's a math artifact of RGB interpolation, and the fix is to interpolate in OKLCH instead.
.hero {
background: linear-gradient(
in oklch,
var(--lime-500) 0%,
var(--magenta-500) 100%
);
}
The in oklch keyword forces the gradient to interpolate in OKLCH space. Now lime to magenta passes through cyan and red, not mud. It's the difference between a gradient that looks designed and one that looks accidental.
color-mix() works the same way:
.glass-tint {
background: color-mix(in oklch, var(--lime-500) 20%, transparent);
}
That gives you a 20% lime tint on a transparent background, mixed in OKLCH so the chroma stays clean. I use this for hover overlays on cards. In sRGB the 20% mix looked dirty over dark backgrounds. In OKLCH it looks like dim lime.
If you're building tokens that scale, the Tailwind v4 Theme: Design Tokens That Actually Scale walks through how to wire the @theme directive so all this fits in one place.
Bottom line
OKLCH is the first color space that thinks like designers do. Lightness is lightness. Chroma is chroma. Hue is hue. Change one, the others hold. That alone fixed three years of low-grade hover-state weirdness for me.
The migration was 2 hours. Find the brand anchor in OKLCH, regenerate the ramp from L stops, swap hover/active states to relative syntax, add in oklch to gradients, drop the hex fallback only if your audience is modern. Done.
If you want a deeper look at the patterns I use across the studio, including dark mode, type scales, and Liquid sections, the full Lab archive lives at /pages/lab-overview. Plenty of code to steal.
The one rule I'd keep: don't migrate everything at once. Pick one component, rebuild it in OKLCH, sit with it for a week. You'll see the bugs you used to live with. Then do the rest.
Top comments (0)