Concept

Switching to OKLCH for Perceptually Uniform Color Tokens

Learn why OKLCH is replacing HSL and RGB in design systems, how perceptual uniformity improves color consistency, and how to migrate your tokens to OKLCH.

FramingUI Team5 min read

Two buttons, both set to "50% lightness." The yellow one looks blindingly bright. The blue one looks dim and understated. You adjust by eye until they feel balanced—but now the numbers are inconsistent and the next person on the team can't understand why.

This is the HSL problem. The lightness channel doesn't correspond to perceived brightness. OKLCH fixes this at the foundation, and that's why Tailwind v4, Radix Colors, and shadcn/ui have all moved to it.

What OKLCH Actually Measures

OKLCH uses three channels: L (lightness, 0–1), C (chroma, vibrancy), and H (hue, 0–360°).

color: oklch(0.65 0.2 250);
/* 0.65 = 65% lightness, 0.2 = moderately saturated, 250 = blue */

What makes it different is that L is perceptually uniform. Two colors with L = 0.65 look equally bright regardless of hue:

oklch(0.65 0.2 60)   /* yellow */
oklch(0.65 0.2 250)  /* blue  */
/* Both read as the same perceived brightness */

Compare to HSL, where both of these are "50% lightness" but look wildly different on screen:

hsl(60, 100%, 50%)   /* yellow — looks almost white */
hsl(240, 100%, 50%)  /* blue   — looks noticeably darker */

The mismatch exists because human vision is more sensitive to yellow wavelengths. HSL's lightness channel doesn't account for this. OKLCH's does.

Three Problems OKLCH Solves

Predictable color scales. Design systems need 9- or 11-step color ramps from lightest to darkest. With HSL, evenly spaced lightness steps produce visually uneven jumps—the step from 90% to 70% looks different from the step from 50% to 30%. With OKLCH, equal numeric steps produce equal perceived steps:

oklch(0.9 0.1 250)  /* lightest */
oklch(0.7 0.1 250)
oklch(0.5 0.1 250)  /* mid */
oklch(0.3 0.1 250)
oklch(0.1 0.1 250)  /* darkest */

Each shade feels one visual step darker than the last. You can generate the entire scale programmatically and trust that it looks balanced.

Consistent saturation across hues. HSL saturation doesn't mean the same thing for every hue. Setting chroma to 0.2 in OKLCH produces visually similar vibrancy for yellow, blue, purple, and red:

oklch(0.65 0.2 60)   /* yellow   — feels equally saturated */
oklch(0.65 0.2 150)  /* green    — feels equally saturated */
oklch(0.65 0.2 250)  /* blue     — feels equally saturated */
oklch(0.65 0.2 330)  /* magenta  — feels equally saturated */

This lets you define multiple accent colors with a single chroma value and have them feel visually matched without manual adjustment.

Better gradients. RGB and HSL gradients pass through desaturated mid-tones when the hue rotates far. OKLCH gradients stay vibrant:

/* Muddy in HSL */
background: linear-gradient(90deg, hsl(0, 100%, 50%), hsl(240, 100%, 50%));

/* Vibrant in OKLCH */
background: linear-gradient(90deg in oklch,
  oklch(0.6 0.25 30),
  oklch(0.6 0.25 270)
);

Migrating to OKLCH

Convert existing colors. oklch.com is a visual converter; paste a hex value and get OKLCH back. colorjs.io handles batch conversion.

/* Before */
--primary: hsl(220, 90%, 56%);

/* After */
--primary: oklch(0.6 0.22 250);

Generate scales programmatically. Once you have a base color in OKLCH, building a full ramp is arithmetic:

function generateScale(baseC, baseH) {
  const lightnesses = [0.95, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25, 0.15];
  return lightnesses.map((l, i) => ({
    step: i + 1,
    value: `oklch(${l} ${baseC} ${baseH})`,
  }));
}

const blue = generateScale(0.2, 250);
// blue[0].value = "oklch(0.95 0.2 250)"  ← lightest
// blue[4].value = "oklch(0.55 0.2 250)"  ← mid
// blue[8].value = "oklch(0.15 0.2 250)"  ← darkest

Use color-mix for interactive states. The color-mix() function respects color space. Mixing in OKLCH keeps hues stable while adjusting brightness:

:root { --primary: oklch(0.6 0.22 250); }

.button:hover  { background: color-mix(in oklch, var(--primary), black 10%); }
.button:active { background: color-mix(in oklch, var(--primary), black 20%); }

RGB-space mixing often shifts hues unexpectedly. OKLCH-space mixing stays on-hue.

Browser support and fallbacks. Chrome 111+, Safari 15.4+, and Firefox 113+ support OKLCH natively—covering well over 90% of users as of 2026. For older browsers, the @csstools/postcss-oklab-function plugin adds RGB fallbacks automatically:

/* Input */
color: oklch(0.6 0.2 250);

/* PostCSS output */
color: rgb(66, 135, 245);        /* fallback */
color: oklch(0.6 0.2 250);       /* modern */

When Not to Switch

OKLCH works poorly when you're bound to exact hex values from a brand guideline. You can convert the hex to OKLCH and confirm it renders identically, but any programmatic scale generation will produce new values that need sign-off. If your brand team owns the palette and specifies colors by hex, coordinate before migrating.

Print workflows stay with CMYK. OKLCH is a display color space.

Legacy browser requirements below the support thresholds above are manageable with the PostCSS plugin, but the plugin doesn't cover CSS color-mix() or gradient interpolation. If those are on your roadmap, coordinate with the browsers you need to support.

How This Changes Design Token Workflows

The practical shift is that you define fewer raw colors manually. You pick a base hue and chroma, generate a scale, and store the OKLCH values as tokens. The token file shrinks because you're not hardcoding nine individually adjusted shades—you're storing a generation function's output.

That output is also easier to audit. You can look at a token and immediately see its brightness (L), vibrancy (C), and hue (H) without running a converter. Changes to the scale are a change to one number, not nine manual adjustments.

For teams using AI tools to generate UI: color tokens in OKLCH are more interpretable by models because the channels carry semantic meaning. oklch(0.6 0.22 250) is clearly a medium-brightness, moderately saturated blue. A model can reason about contrast and pairing without needing external lookup. That makes generated color usage more consistent.

Ready to build with FramingUI?

Build consistent UI with AI-ready design tokens. No more hallucinated colors or spacing.

Try FramingUI
Share

Related Posts