Why OKLCH is the Only Color Space That Makes Sense for Theming
Why OKLCH is the Only Color Space That Makes Sense for Theming
Every color system I built before OKLCH was held together by manual overrides and gut feelings. I'd pick a nice blue, generate a palette, build a dark mode, and then spend hours tweaking individual colors because some looked fine and others looked like garbage. The palette generator wasn't broken. The color space was.
HSL is the default mental model for most engineers and designers working with color on the web. It's intuitive -- you pick a hue, set saturation, adjust lightness. Simple. And completely wrong for building anything systematic.
The HSL Lie
HSL tells you two colors have the same lightness when they don't.
background: hsl(60, 100%, 50%); /* yellow */
background: hsl(240, 100%, 50%); /* blue */
Both have a lightness of 50%. Put them side by side. Yellow is screaming at you. Blue is subdued. Your eyes are not lying -- these two colors have wildly different perceived brightness despite HSL claiming they're equal.
This isn't a minor inconvenience. It's a fundamental flaw that breaks every systematic approach to color. If "same lightness" doesn't mean "same perceived brightness," then you can't generate consistent palettes by adjusting lightness. You can't build reliable contrast ratios. You can't do dark mode without manually tweaking every single color.
And that's exactly what most of us have been doing.
The Dark Mode Tax
I've built dark mode for my own site and for production products. Every time I used HSL, the process was the same: define light theme colors, invert the lightness values for dark theme, stare at the result, and start fixing things by hand.
Some colors inverted beautifully. Others looked washed out. Some lost all their vibrancy. Others became harsh and unreadable against a dark background. The problem was always the same -- HSL lightness inversion produces uneven perceptual results because the lightness axis isn't perceptually uniform.
So you end up with a "system" that's really just a collection of hand-tuned values. Your light theme has one set of carefully picked colors. Your dark theme has another set of carefully picked colors. The relationship between them is "I eyeballed it until it looked right." That's not a system. That's a design exercise you'll have to repeat every time you add a new color.
I got tired of it.
What OKLCH Actually Is
OKLCH stands for Oklab Lightness, Chroma, Hue. It's a perceptually uniform color space, which means equal numerical changes produce equal perceived changes.
Three values:
- L (Lightness) -- 0 is black, 1 is white, and every step between produces an equal step in perceived brightness
- C (Chroma) -- how vivid the color is, from gray to fully saturated
- H (Hue) -- the actual color, 0-360 degrees around the color wheel
The key innovation is in L. Unlike HSL, where L=50% means completely different things for yellow and blue, OKLCH's lightness is calibrated to human perception. L=0.7 looks equally bright whether the hue is red, blue, green, or yellow. That's the whole game.
Once you have a lightness axis you can trust, everything else falls into place.
The Mirror Rule
This is where it gets practical. If your lightness axis is perceptually uniform, dark mode becomes arithmetic.
I use a step-based palette: steps from 50 (lightest) to 950 (darkest), all derived from OKLCH values. The mirror formula is dead simple:
dark_step = 1000 - light_step
Light theme background at step 50? Dark theme background is step 950. Light theme text at step 900? Dark theme text is step 100. Light theme border at step 200? Dark theme border is step 800.
Because the steps are perceptually uniform, these mirrors produce visually equivalent contrast in both themes. No tweaking. No eyeballing. The math just works.
Here's the same idea in raw OKLCH values. A light blue background:
background: oklch(0.95 0.02 250);
Its dark mode counterpart:
background: oklch(0.15 0.02 250);
Same chroma. Same hue. Mirrored lightness. The relationship is deterministic. Try doing that with HSL -- you'll be adjusting saturation and lightness independently for every hue, and you still won't get balanced results.
What This Looks Like in Practice
Here's a simplified version of how my site handles theming. CSS custom properties with semantic names, each mapped to a palette step:
body {
--c-bg: #fff;
--c-light: #f1f5f9; /* slate-100 */
--c-border: #e2e8f0; /* slate-200 */
--c-font-light: #6b7d99; /* slate-500 */
--c-font: #45556c; /* slate-600 */
--c-font-dark: #0f172b; /* slate-900 */
}
body[data-theme='dark'] {
--c-bg: #171717;
--c-light: #262626; /* neutral-800 */
--c-border: #3a3a3a; /* neutral-700 */
--c-font-light: #a1a1a1; /* neutral-400 */
--c-font: #d4d4d4; /* neutral-300 */
--c-font-dark: #f5f5f5; /* neutral-100 */
}
The step annotations aren't decoration. They're the contract. When I see /* slate-200 */ on a border, I know the dark mode equivalent is somewhere around step 800. I know the perceptual weight. I know it will produce the same visual hierarchy in both themes.
The hex values in the stylesheet are the output. OKLCH is the derivation source. You don't write oklch() in your production CSS if you don't want to -- you use OKLCH to generate perceptually correct hex values, then use those. The thinking happens in OKLCH. The code ships in hex.
The contrast hierarchy is preserved in both themes without any manual adjustment:
font-dark > font > font-light > border > light > bg
That ordering holds in light mode and dark mode because the step system guarantees it. Change one variable, and the hierarchy stays intact. Add a new semantic color, look up its step, derive the mirror, done. No design review needed for every new color in both themes.
Why HSL Can't Be Fixed
It's not that nobody has tried. HSL has been the web's color model for decades, and people have built elaborate systems on top of it -- scale generators, contrast checkers, palette tools that compensate for HSL's non-uniformity.
But compensating for a broken foundation is inherently fragile. Every workaround is hue-dependent. Yellow needs different treatment than blue. Green needs different treatment than red. Your "system" becomes a lookup table of exceptions.
OKLCH doesn't need exceptions because the foundation is correct. Equal steps produce equal perception. That's it. No hue-specific adjustments. No compensation curves. No special cases.
Browser Support Is Not an Excuse Anymore
The oklch() CSS function is supported in all modern browsers -- Chrome, Firefox, Safari, Edge. It's been stable since 2023. If you're still avoiding it because of browser support concerns, you're three years behind.
And even if you don't want to use oklch() directly in your stylesheets, you can use OKLCH as a design tool. Generate your palette in OKLCH, export as hex, and use the hex values. You get the perceptual correctness without any browser compatibility considerations.
The palette I use was derived entirely from OKLCH. Every step, every color family -- red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose, and the neutral families. All computed in OKLCH, all exported as hex. The browser never sees an oklch() function. But the colors are right because the math behind them is right.
Accessible Contrast for Free
Here's the part that should matter to everyone building production UIs: OKLCH makes WCAG contrast ratios predictable.
In HSL, checking contrast is an afterthought. You pick colors that look good, then run them through a contrast checker, then adjust when they fail, then check again. It's a loop of trial and error.
In OKLCH, contrast correlates directly with lightness difference. If you know two colors are 500 steps apart (say, step 200 background and step 700 text), you know the contrast will be substantial. The exact WCAG ratio still depends on chroma and hue, but the ballpark is predictable from the lightness gap alone.
This means you can design for accessibility from the start instead of checking for it at the end. Pick your steps, know your contrast, ship. If your text is step 900 on a step 50 background, you don't need a tool to tell you it passes -- the perceptual gap is massive by definition.
The Shift
The real shift isn't technical. It's conceptual. HSL tricks you into thinking lightness is a universal axis. It's not. Two colors can share the same HSL lightness and look completely different to a human eye.
Once you internalize that HSL lightness is a mathematical property and OKLCH lightness is a perceptual one, you stop fighting your own tools. Dark mode becomes a formula. Palette generation becomes deterministic. Contrast becomes predictable. You spend your time on design decisions that matter -- which hues, which semantic roles, which visual hierarchy -- instead of manually compensating for a color space that can't do basic math on brightness.
I'm not going back. Every project I build uses OKLCH-derived palettes now. The theming code is smaller. The dark mode works on the first try. The colors look right because the numbers mean what they say.
That's all I ever wanted from a color space.
Design