Article Post

A complete blog post for a minimal tech blog. Composes the article header, a prose body, and the article footer into a single reading experience. Monochromatic with one accent color; typography does the work.

Core: .flow, .cluster, .divider, .prose

Custom Styles

A few custom styles are included in the page style block used by some of the patterns.

<style>
    .article-category {
        text-transform: uppercase;
        letter-spacing: 0.05em;
        color: var(--color-primary-text);
        font-size: var(--step--2);
        font-weight: var(--font-weight-medium);
    }
    .article-meta {
        display: flex;
        align-items: center;
        gap: var(--space-sm);
        flex-wrap: wrap;
        color: var(--color-text-muted);
        font-size: var(--step--1);
    }
</style>

Full post

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.


  1. 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.

  2. 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.

  3. This approach is sometimes called a "chroma curve." Stripe's design team documented a similar technique in their 2022 color system writeup.


<docs-resize>
    <article class="flow flow-space-lg" style="max-inline-size: 65ch">

        
        <header class="flow flow-space-sm">
            <p class="article-category">CSS Architecture</p>
            <h1 class="text-4 text-semibold leading-tight">Building a Color System with oklch</h1>
            <p class="text-1 text-muted">
                How we moved from HSL to oklch for perceptually uniform palettes across light and dark themes.
            </p>
            <div class="article-meta">
                <span>Jordan Lee</span>
                <span>·</span>
                <time datetime="2026-03-15">March 15, 2026</time>
                <span>·</span>
                <span>8 min read</span>
            </div>
        </header>

        <hr class="divider"/>

        
        <div class="prose">
            <p>
                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&#39;t our implementation -- it was the color space itself.<sup><a href="#fn:1" id="fnref:1">1</a></sup>
            </p>

            <h2>Why HSL breaks down</h2>
            <p>
                HSL distributes lightness uniformly across hues, but human perception doesn&#39;t. A yellow at <code>hsl(60, 100%, 50%)</code> looks far brighter than a blue at <code>hsl(240, 100%, 50%)</code>, 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.
            </p>
            <p>
                This matters less when you&#39;re picking colors one at a time. It matters a lot when you&#39;re generating a systematic palette where every hue needs to feel like it belongs at the same visual weight.
            </p>

            <h2>Enter oklch</h2>
            <p>
                oklch is a perceptually uniform color space. If two colors share the same lightness value, they actually look equally bright to a human observer.<sup><a href="#fn:2" id="fnref:2">2</a></sup> This is the property we needed.
            </p>
            <blockquote>
                <p>The best color space is the one where equal numbers produce equal perceptions.</p>
                <cite>Bjorn Ottosson, creator of oklab</cite>
            </blockquote>
            <p>
                The migration wasn&#39;t trivial. oklch uses three channels -- lightness, chroma, and hue -- that don&#39;t map cleanly to HSL. We couldn&#39;t just convert values; we had to rebuild the palette from first principles.
            </p>

            <h3>The palette structure</h3>
            <p>
                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.<sup><a href="#fn:3" id="fnref:3">3</a></sup> This produces stops that feel natural across the entire range.
            </p>
            <pre><code>--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));</code></pre>

            <h2>What we learned</h2>
            <ul>
                <li>Perceptual uniformity makes dark mode almost free -- invert the lightness scale and the palette stays balanced</li>
                <li>Chroma needs to taper at both ends of the lightness range, not just the light end</li>
                <li>Browser support is excellent -- oklch works in all modern browsers with no fallback needed</li>
                <li>The hardest part was letting go of our old HSL intuitions about what &#34;50% lightness&#34; means</li>
            </ul>

            <h2>Was it worth it?</h2>
            <p>
                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&#39;re building a color system from scratch, start with oklch.
            </p>

            <section class="footnotes">
                <hr/>
                <ol>
                    <li id="fn:1">
                        <p>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. <a href="#fnref:1">↩</a></p>
                    </li>
                    <li id="fn:2">
                        <p>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. <a href="#fnref:2">↩</a></p>
                    </li>
                    <li id="fn:3">
                        <p>This approach is sometimes called a &#34;chroma curve.&#34; Stripe&#39;s design team documented a similar technique in their 2022 color system writeup. <a href="#fnref:3">↩</a></p>
                    </li>
                </ol>
            </section>
        </div>

        <hr class="divider"/>

        
        <footer>
            <div class="flow flow-space-sm">
                <div class="cluster gap-xs">
                    <span class="article-category">Tagged:</span>
                    <a href="#" class="text--1 text-muted" style="text-decoration: none">oklch</a>
                    <a href="#" class="text--1 text-muted" style="text-decoration: none">color systems</a>
                    <a href="#" class="text--1 text-muted" style="text-decoration: none">dark mode</a>
                    <a href="#" class="text--1 text-muted" style="text-decoration: none">design tokens</a>
                </div>
                <div class="flow flow-space-2xs text--1 text-subtle">
                    <p>
                        Published <time datetime="2026-03-15">March 15, 2026</time>
                        · Updated <time datetime="2026-03-20">March 20, 2026</time>
                    </p>
                    <p>
                        Written by <span style="color: var(--color-text)">Jordan Lee</span> -- building tools for design systems and writing about the process.
                    </p>
                </div>
            </div>
        </footer>

    </article>
</docs-resize>

Accessibility