CSS Architecture
Building a Color System with oklch
How we moved from HSL to oklch for perceptually uniform palettes across light and dark themes.
We had been using HSL for three years when the first dark mode bug reports started rolling in. Colors that looked balanced in light mode turned muddy or garish when inverted. The problem wasn't our implementation -- it was the color space itself.1
Why HSL breaks down
HSL distributes lightness uniformly across hues, but human perception doesn't. A yellow at hsl(60, 100%, 50%) looks far brighter than a blue at hsl(240, 100%, 50%), even though both sit at 50% lightness. When you build a palette by shifting hue while holding saturation and lightness constant, the results are perceptually uneven.
This matters less when you're picking colors one at a time. It matters a lot when you're generating a systematic palette where every hue needs to feel like it belongs at the same visual weight.
Enter oklch
oklch is a perceptually uniform color space. If two colors share the same lightness value, they actually look equally bright to a human observer.2 This is the property we needed.
The best color space is the one where equal numbers produce equal perceptions.
Bjorn Ottosson, creator of oklab
The migration wasn't trivial. oklch uses three channels -- lightness, chroma, and hue -- that don't map cleanly to HSL. We couldn't just convert values; we had to rebuild the palette from first principles.
The palette structure
We settled on a nine-stop scale for each hue, with lightness values distributed from 95% to 22%. The key insight was keeping chroma proportional to lightness: higher lightness gets lower chroma, and the mid-range peaks.3 This produces stops that feel natural across the entire range.
--primary-100: oklch(95% 0.04 var(--hue-primary));
--primary-500: oklch(55% 0.15 var(--hue-primary));
--primary-900: oklch(22% 0.07 var(--hue-primary));
What we learned
- Perceptual uniformity makes dark mode almost free -- invert the lightness scale and the palette stays balanced
- Chroma needs to taper at both ends of the lightness range, not just the light end
- Browser support is excellent -- oklch works in all modern browsers with no fallback needed
- The hardest part was letting go of our old HSL intuitions about what "50% lightness" means
Was it worth it?
Yes. The dark mode bugs disappeared. New hues can be added by changing a single custom property. The palette looks intentional in a way that our HSL version never quite managed. If you're building a color system from scratch, start with oklch.
-
The earliest reports came from our dashboard team, who had built a data visualization layer on top of our HSL palette. Colors that were distinguishable in light mode became nearly identical in dark mode. ↩
-
Bjorn Ottosson published the oklab paper in 2020. oklch is the cylindrical form of oklab, using lightness, chroma, and hue instead of lightness and two color axes. ↩
-
This approach is sometimes called a "chroma curve." Stripe's design team documented a similar technique in their 2022 color system writeup. ↩