Alejo Pequeño

Design Engineer

Dynamic color theming with OKLCH

You want a UI that adopts an arbitrary color and feels tinted by it everywhere: backgrounds, surfaces, borders, accents. The color arrives at runtime and you don't get to pick it ahead of time. A user preference, a brand value from an API, an image you sampled.

The obvious risk: let any color paint your backgrounds and text and the contrast falls apart. A dark input gives you unreadable text, a neon one blinds you. The naive fix ("mix the color into the background at 10%") works sometimes and breaks the rest of the time, because RGB and HSL don't model how we see brightness. You need a color space where the numbers mean what you think they mean.

Play with it first

Here is the whole idea in one box. Drag the sliders, or paste a color in any format (#hex, rgb(...), hsl(...), oklch(...)) and watch it show up in all of them. Push chroma too far and it leaves the sRGB gamut; the clamp button pulls it back to the edge.

max chroma here: 0.204In sRGB

Lightness, chroma, hue. Three numbers, and each one moves one thing.

Why OKLCH

In hex and RGB the numbers describe how to light up subpixels, not how you perceive the result. In HSL, lightness: 50% looks blinding on yellow and dim on blue, and the hue drifts as you change brightness. OKLCH is perceptually uniform: equal steps of L are equal steps of perceived brightness, and it splits color into three axes that actually stay independent.

AxisWhat it isRange
L — LightnessPerceived brightness0–1 (0 black, 1 white)
C — ChromaHow far from gray0 to ~0.4
H — HueThe tone itself0–360°

OKLCH is a cylinder: lightness runs up the middle, chroma is the distance out from the gray core, and hue is the angle around it. So hue is a wheel. Drag around the ring, or move L and C and watch which colors the ring can even reach. The angles are sampled from OKLCH itself, so blue sits near 264°, not at 240° where an sRGB wheel would put it.

hue 255.0°
oklch(0.65 0.16 255)
max chroma here: 0.190

Two properties do the heavy lifting. First, same L reads as same brightness across every hue. Rotate the hue and the row stays even. HSL, with the same lightness number, has the green jumping out and the blue sinking:

OKLCH
HSL

Second, hue holds when you change lightness. Take one color and lighten it step by step. OKLCH keeps the hue from shadow to highlight; HSL sends the lights pale toward violet and the darks muddy, even though the hue number never moved:

OKLCH
HSL

Those two together lead to the one consequence that makes safe theming possible: contrast is, in practice, a function of L. Two colors with the same L have about the same brightness regardless of hue or chroma. So if I freeze the L of a surface and the L of its text, no input color can break the contrast between them.

The technique: freeze the lightness

Give every surface a fixed L constant. From the incoming color, let only the hue and a capped fraction of chroma travel. The L never moves.

--background: oklch(0.17 var(--surface-c-bg) var(--tint-h));
--card: oklch(0.21 var(--surface-c-card) var(--tint-h));
--foreground: oklch(0.96 var(--surface-c-fg) var(--tint-h));

--surface-c-bg isn't a color, it's how much saturation the background is allowed. The 0.17 is nailed down and the input color never touches it. Set the injected chroma to zero and every surface collapses to neutral gray, so the "no theme" state is just the same ramp with a zero input. No special case, no code branch.

The accent (buttons, links, focus rings) is the exception: it wants to be the color, not a surface. There you use the color's real lightness, clamped to a safe band so it's never invisible or blinding: clamp(l, 0.45, 0.65). Hue and chroma pass through untouched.

Here it is end to end. Pick a hue, set how much of it bleeds into the surfaces, and the little UI re-tints itself. The lightness of each piece stays put the whole time:

Frozen lightness, dynamic hue

Every surface keeps its lightness. Only the hue and a sliver of chroma come from the picked color, so contrast never breaks.

--background: oklch(0.17 0.003 255)
--card: oklch(0.21 0.005 255)
--foreground: oklch(0.96 0.001 255)
--accent: oklch(0.6 0.138 255)

How much chroma per surface

Each step of the ramp gets a different amount of saturation:

surface_chroma = max(
  floor,
  vividness × input_saturation × layer_budget × maxChroma(L, hue)
)

vividness is one global knob for how much theme reaches the surfaces. input_saturation is the input's chroma over its own max, so a dull color tints lightly and a vivid one tints more. layer_budget is each step's own fraction, which shapes the curve. And maxChroma(L, hue) is the physical ceiling: the most chroma that fits in sRGB at that lightness and hue.

That last one isn't optional. The screen's gamut can't show every L/C/H combination. Feed a lightness too much chroma and the browser clips it, and clipping shifts the L, which breaks the contrast guarantee. Always clamp chroma to the in-gamut max for that L and H. In JavaScript that's culori's clampChroma(), which is exactly what the demos above use before painting anything.

What's physics and what's a knob

When you port this to another project, some of it is fixed and some is yours to tune. The universal part: maxChroma(L, hue), the bell shape of the gamut, "freeze L and contrast holds", and clamping chroma to the gamut. The per-brand part: the vividness constant, the budget for each layer, the actual L values of your ramp, and the accent's safe range.

The skeleton is theory you copy as is. The numbers are a recipe you calibrate by eye against the neutral palette you already have.

Cheat sheet

oklch(L C H)          /* oklch(0.62 0.19 255) */
oklch(L C H / alpha)  /* oklch(0.8 0.05 200 / 0.5) */

L is 0–1, C is 0 to about 0.4, H is 0–360°. Alpha uses a slash, not a comma.

To fix a contrast problem, adjust L and leave C and H alone. Chroma barely affects contrast. Anything above L 0.6 is a light background and wants dark text. And if a palette ramp looks off, it's usually hue drift: check the spread between steps.