Engineering
Minimalist abstract spectral OKLCH color gamut waves and luminous gradient sphere with fine film grain
• • 9 min read

Designing With OKLCH: Perceptually Uniform Color for Modern Interfaces

Build perceptually uniform dark-mode palettes with OKLCH and keep micro-interactions on the compositor: modern CSS for performant, crafted interfaces.

Every hand-tuned dark theme eventually accumulates the same graveyard: a comment next to a hex value explaining why this gray is 3% lighter than that one, a hover state someone brightened twice, an accent that looked vivid on one monitor and muddy on another. The values are not wrong—they are compensating for a coordinate system that does not describe what humans actually see. HSL promises intuitive color and delivers numbers whose perceptual meaning shifts under your feet.

OKLCH is the modern alternative, and it is more than a syntax upgrade: it changes which adjustments are safe to automate. Paired with a little discipline about which CSS properties animate, it supports interfaces that feel engineered rather than decorated—consistent contrast by construction, motion that never costs a layout pass, and themes derived from a handful of tokens instead of forty magic constants. That restraint-first posture is the same philosophy behind how we build Awesome Crate, and it scales down to any project where craft matters more than ornament.

Why HSL dark themes drift into mud

HSL’s core promise—set hue, saturation, and lightness independently—is a lie told in good faith. The L channel measures mathematical lightness in RGB terms, not perceived brightness, and human vision weights green heavily over blue. Concretely:

  • Same L, wildly different brightness. hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) both claim 50% lightness, yet yellow reads as a sunburst and blue as deep twilight. Any system generating colors at fixed L produces uneven output across hues.
  • Rotating hue skews everything else. Interpolate between two hues in HSL and the path can swing through grays or spike through neon, because the intermediate RGB values do not track perception.
  • Derivation needs magic constants. Want a darker variant of the brand accent? In HSL you guess how much L to subtract, then eyeball the result per hue. Multiply that across background, surface, text, border, and state variants times light and dark modes, and the theme becomes a spreadsheet of hand corrections.

This is why so many dark themes look either washed out or crushed: the author was tuning numbers that did not correspond to the sensation they were trying to control.

OKLCH in one mental model

OKLCH describes color with three axes that map to intuition honestly:

  • L — perceptual lightness, from 0% (black) to 100% (white), calibrated so equal steps look equal regardless of hue. Two colors with L = 70% genuinely read as equally lit.
  • C — chroma, how colorful versus neutral, from 0 (pure gray) upward. Unlike HSL’s saturation percentage, chroma has no arbitrary ceiling; a color simply stops existing when its chroma exceeds what the display can produce.
  • H — hue, the familiar angle around the color wheel.

The underlying model (Oklab, by Björn Ottosson) transforms sRGB values into a space built to match how human cone cells respond, then re-expresses that in cylindrical form. Perceptual uniformity is not marketing here—it means arithmetic on the coordinates corresponds to arithmetic on sensations. Raise L by 10 and every hue in your palette gets uniformly brighter. Drop C toward zero and everything converges on the same gray ramp.

One practical consequence deserves its own paragraph: gamut. Wide-gamut colors (vivid chroma) exist on P3 displays but not in classic sRGB, so browsers perform gamut mapping—they clip or compress chroma toward the display’s range. Because OKLCH separates lightness from colorfulness, that mapping degrades gracefully: a clipped color keeps its lightness and loses saturation rather than shifting brightness unpredictably. Declare intent with @supports (color: oklch(0% 0 0)) when you want fallbacks only where needed.

Wide-gamut work rewards one concrete habit: choose accent colors in Display-P3 territory deliberately, then inspect them on an sRGB display before committing, since automatic clipping can desaturate more aggressively than the eye expects. Interfaces that depend on subtle chroma differences—status colors, chart series—suffer most when gamut mapping goes unexamined; neutral-heavy dark themes like the scale below barely notice.

Building a dark-mode scale from first principles

A theme stops being a pile of hexes when each value is derived. Anchor a hue, fix a small set of lightness steps, and let chroma stay deliberately low except for accents:

:root {
  /* One knob steers the entire theme. */
  --hue: 200;

  /* Neutral ramp: low, constant chroma; evenly spaced lightness. */
  --bg:        oklch(16% 0.012 var(--hue));
  --surface:   oklch(21% 0.018 var(--hue));
  --surface-2: oklch(26% 0.022 var(--hue));
  --line:      oklch(34% 0.02  var(--hue));
  --text:      oklch(93% 0.008 var(--hue));
  --text-dim:  oklch(72% 0.012 var(--hue));

  /* Accent: high chroma, lightness chosen for contrast against bg. */
  --accent:       oklch(78% 0.14 calc(var(--hue) - 40));
  --accent-hover: oklch(83% 0.14 calc(var(--hue) - 40));
}

Read what this buys. Hover states are not invented—they are nudges along axes that mean something (+5% lightness on the accent). A whole re-theme is a one-variable change. And because lightness is honest, accessibility review collapses into checking pairs: --text against --bg, --text-dim against --surface.

Migrating an existing theme rewards an incremental path. Alias current hex values into custom properties first without changing them—pure plumbing, instantly testable. Then convert the neutrals (backgrounds, surfaces, text), matching each old value’s perceived lightness and comparing pairs side-by-side before deleting the original. Accents move last, one at a time, because they carry brand recognition and deserve individual judgment. Teams that attempt a big-bang conversion usually discover half their old values were compensating for each other—knowledge worth surfacing deliberately.

Interactive states inherit the same arithmetic:

.button {
  background: var(--accent);
}
.button:hover         { background: var(--accent-hover); }
.button:active        { background: oklch(73% 0.14 calc(var(--hue) - 40)); } /* −5% L */
.button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

Each rule reads as a nudge along an axis rather than another entry in a palette spreadsheet—the difference compounds as the interface grows.

The same derivation logic powers interactive states everywhere: :hover lifts --surface by a step, :focus-visible swaps --line for --accent, disabled controls drop chroma before they drop lightness. Each rule reads as intent instead of a new magic number.

Motion that costs nothing

Color earns attention; motion should spend almost none of the browser’s. The renderer’s cheapest work happens on the compositor thread, and only two properties get compositor-only treatment: transform and opacity. Animate anything else—top, width, box-shadow, color—and every frame re-runs style recalculation, layout, and paint.

.card {
  transition:
    transform 280ms cubic-bezier(0.2, 0.7, 0.2, 1),
    opacity   180ms ease-out;
}

.card:hover {
  transform: translateY(-2px);
}

Two lines of discipline extend that snippet. First, will-change is a scalpel, not seasoning: apply it just before a known heavy animation and remove it after, because promoting every element to its own layer exhausts GPU memory and causes the jank it promises to prevent. Second, easing curves carry brand voice—a single custom cubic-bezier reused site-wide makes interactions recognizably yours in a way five different stock easings never will.

Craft also includes refusal. prefers-reduced-motion exists because vestibular disorders make animated interfaces physically unpleasant for a real share of users; collapsing animations to near-zero durations is a courtesy the medium owes:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 1ms !important;
    animation-duration: 1ms !important;
  }
}

One classic pattern deserves rehabilitation in compositor terms: hover overlays. Transitioning background-color repaints every frame; layering a semi-transparent element and fading its opacity achieves the same visual with two compositable properties instead of one expensive one. The trick generalizes—focus rings as opacity fades on a pseudo-element, theme cross-fades as an overlaid copy of the next palette dissolving over the current one.

We explored the design side of calm, ambient motion separately in designing focus environments; the CSS mechanics above are what keep such environments cheap enough to run indefinitely.

Zero-runtime styling habits

Modern vanilla CSS absorbs much of what JS-in-CSS frameworks once justified. Custom properties give theming without runtime style injection; clamp() handles fluid type scales in one declaration; :has() expresses parent-state styling that used to demand JavaScript; native nesting removes preprocessor dependency for most projects. Every behavior moved from script to stylesheet is work the engine schedules efficiently and users never wait on.

/* Parent reacts to child state—formerly JavaScript's job: */
.field:has(input:user-invalid) { --line: oklch(58% 0.19 25); }

The honest caveat: build-time tooling still earns its keep at scale—prefix management, minification, dead-code elimination. The line worth holding is runtime: if styling decisions execute in the browser on every render, they belong on the compositor-friendly side of this article’s rules or in a stylesheet the engine caches. Static output, dynamic variables—that split keeps paint times sub-millisecond even as themes grow.

Questions people often ask

Is OKLCH supported everywhere I need it?

All evergreen browsers have shipped oklch() since early 2023, so for general web work the answer is yes. Legacy enterprise targets may lag; feature-query your way around them.

What is the graceful fallback?

Declare a reasonable sRGB value first and the OKLCH form second inside a @supports block, or rely on the cascade: browsers that cannot parse oklch() ignore the declaration and keep the previous one. Colors degrade, layouts never break.

How does OKLCH relate to Lab and LCH?

Same family, different calibration. CIELAB’s lightness axis predates modern psychophysics and skews on blues; Oklab corrected those biases, and OKLCH is its cylindrical form. For new work, prefer OKLCH—the numbers behave.

Which tools help pick OKLCH values?

Several browser devtools color pickers now display and edit OKLCH directly, and community palette generators exist for building ramps. Verify any generated scale in situ—perception shifts with surrounding colors, and your interface is the only context that matters. When evaluating a generator, also check how it handles chroma clamping: palettes that silently exceed sRGB will look different once gamut-mapped on narrower displays.

The takeaway

Perceptually uniform color turns theme engineering from constant touch-ups into derivation: anchor a hue, step lightness deliberately, keep neutrals quiet, spend chroma only where emphasis is earned. Then keep interaction cost proportional—transform and opacity on the compositor, reduced-motion respected as baseline craft. Interfaces built this way feel calmer precisely because nothing in them is fighting the platform. The aesthetic lineage traces straight through Awesome Crate—ambient surfaces, restrained motion, contrast that holds up in the dark—and none of it requires more CSS than shown above.

ADVERTISEMENT
SPREAD THE WORD

Found this guide helpful? Share it with your team & network.

ADVERTISEMENT
Author

Author

Verified

Engineer at Anirone, building Awesome Crate — free browser tools that keep your files on your device — and writing about how they work.