Stop Using HEX Colors: A Practical Guide to oklch()

Perceptually even colour, predictable lightness steps, wide-gamut output, and the contrast caveat that usually goes unmentioned.

csstutorialbeginnerdesign

Picking a HEX color is guesswork. If a shade comes out too dark, you open a picker, nudge a channel, and hope the hue stayed put. oklch() separates lightness, chroma and hue, so you can change one without recalculating the other two.

What Is oklch?

oklch is OK (a perceptual correction) plus Lightness, Chroma and Hue. It sits on Oklab, where equal numeric steps look like equal perceptual steps. RGB and HSL don't have that property.

The syntax:

color: oklch(L C H);
/* or with alpha */
color: oklch(L C H / alpha);
  • L — Lightness, 0 (black) to 1 (white). Also written as 0% to 100%.
  • C — Chroma (saturation), 0 (gray) to ~0.4 (vivid). Higher values are wider-gamut.
  • H — Hue angle, 0 to 360 (same wheel as HSL).

Why oklch Beats HSL

HSL's lightness channel lies to you. Two colors at the same value can look nothing alike:

/* Both at 50% HSL lightness — but yellow looks much brighter */
hsl(60deg 100% 50%)   /* Yellow — eye-searingly bright */
hsl(240deg 100% 50%)  /* Blue — much darker perceptually */

Yellow at 50% glares; blue at 50% reads as a deep navy. In oklch, the same lightness value looks the same:

oklch(0.7 0.2 60)   /* Yellow — consistent perceived lightness */
oklch(0.7 0.2 240)  /* Blue — same perceived lightness */

Perceptual lightness isn't contrast, though. Matching L values won't fix unreadable text, and I've shipped a palette like that.

Building a Color Palette

The useful part is generating a palette by varying one channel. Hold L and C, step the hue:

:root {
    /* Base palette — same L and C, varying H */
    --red:    oklch(0.65 0.22 25);
    --orange: oklch(0.65 0.22 55);
    --yellow: oklch(0.65 0.22 90);
    --green:  oklch(0.65 0.22 145);
    --teal:   oklch(0.65 0.22 185);
    --blue:   oklch(0.65 0.22 250);
    --purple: oklch(0.65 0.22 300);
    --pink:   oklch(0.65 0.22 340);
}

All eight read at roughly the same weight. The same set in HSL needs manual brightness fixes, especially around yellow and blue.

Generating Shades

Shades are the same trick on the lightness axis:

:root {
    --brand-50:  oklch(0.97 0.05 260);
    --brand-100: oklch(0.93 0.08 260);
    --brand-200: oklch(0.86 0.12 260);
    --brand-300: oklch(0.76 0.16 260);
    --brand-400: oklch(0.65 0.20 260);
    --brand-500: oklch(0.55 0.22 260); /* Base */
    --brand-600: oklch(0.44 0.20 260);
    --brand-700: oklch(0.34 0.17 260);
    --brand-800: oklch(0.25 0.12 260);
    --brand-900: oklch(0.16 0.07 260);
}

Chroma tapers at both ends so the light and dark steps don't look neon. That curve came from trial and error.

Wide Gamut Colors

oklch can describe colors outside the sRGB gamut, including P3 and Rec2020:

.vivid {
    /* This is more saturated than any sRGB color can express */
    color: oklch(0.7 0.35 145);
}

The browser maps that into whatever the display can show. On an sRGB monitor you get a muted green; on a P3 display the same value is noticeably more saturated. If the exact color matters on sRGB screens, keep the values inside the smaller gamut.

Using oklch in Tailwind CSS

Tailwind v4 defines its default palette in oklch. Custom colors are plain strings in the theme config:

// tailwind.config.js
module.exports = {
    theme: {
        colors: {
            brand: {
                500: "oklch(0.55 0.22 260)",
                600: "oklch(0.44 0.20 260)",
            },
        },
    },
};

Tools

  • oklch.com — interactive picker with P3 visualization.
  • Evil Martians oklch tool — palette generator.
  • Chromatic — VS Code extension with oklch support.

I haven't reached for a HEX value for a new palette in a while. Convert a handful of your tokens and see whether the shades you keep fighting with were badly chosen numbers.