EmpireUI
Get Pro
← Blog7 min read#color-tokens#oklch#hsl

Color Palette Engineering: HSL, OKLCH, and Semantic Token Mapping

HSL is old news — OKLCH is how modern design systems handle perceptual color. Here's how to build semantic token maps that actually scale across themes.

Abstract colorful spectrum gradient showing smooth color transitions across a spectrum

Why HSL Isn't Enough Anymore

Honestly, HSL felt like a revolution in 2010. It gave us human-readable color — hsl(210, 80%, 55%) instead of #3b82f6. You could mentally reason about hue, saturation, lightness. That was a genuine improvement over hex soup.

But HSL has a dirty secret: equal lightness values don't look equally light. Set two colors to L=50% — one yellow, one blue — and the yellow will visually dominate every single time. That's because HSL's lightness channel is calculated in the sRGB color space, which has almost nothing to do with how human eyes perceive brightness.

You'll run into this the moment you try to build an accessible color ramp. You pick --blue-500 with L=50% and --yellow-500 with L=50%, run a contrast check against white, and one fails while the other passes comfortably. The math says they're equal. Your eyes — and WCAG — say otherwise.

This is exactly the problem OKLCH was designed to solve. It models perceptual uniformity, meaning a jump in lightness from 0.5 to 0.6 looks the same regardless of hue. That changes everything about how you build scalable color systems.

OKLCH: The Color Space Your Tokens Have Been Waiting For

OKLCH stands for Oklab Lightness Chroma Hue. It's derived from the Oklab color space, which was designed specifically to fix the perceptual uniformity problems in earlier models. In OKLCH, L ranges from 0 to 1, C is chroma (roughly saturation, but unbounded), and H is hue in degrees from 0 to 360.

Browser support landed in Chromium 111, Firefox 113, and Safari 15.4. As of mid-2026, you can use it without a polyfill in every major browser. Tailwind v4.0.2 uses OKLCH internally for its default color palette — that's not a coincidence.

The real benefit shows up when you generate color ramps programmatically. In OKLCH, you can step L from 0.95 down to 0.15 in equal increments and get a visually uniform scale — no manual tweaking, no surprise contrast failures at the mid-tones. Compare that to HSL where you're constantly compensating for the yellow-blue luminosity gap.

Here's what a semantic token definition looks like using OKLCH in a CSS custom property system:

:root {
  /* Raw OKLCH palette */
  --blue-100: oklch(0.95 0.04 250);
  --blue-300: oklch(0.78 0.12 250);
  --blue-500: oklch(0.60 0.20 250);
  --blue-700: oklch(0.42 0.18 250);
  --blue-900: oklch(0.25 0.10 250);

  /* Semantic tokens — reference the palette */
  --color-primary:       var(--blue-500);
  --color-primary-hover: var(--blue-700);
  --color-primary-subtle: var(--blue-100);
}

.dark {
  --color-primary:       var(--blue-300);
  --color-primary-hover: var(--blue-100);
  --color-primary-subtle: var(--blue-900);
}

Semantic Token Mapping: Two-Layer Architecture

The biggest mistake teams make is skipping the abstraction layer. They define --blue-500 and use it directly in components. Then the dark mode requirement arrives and they're grepping through 200 files to change blue references. Don't do that.

The correct architecture has two layers. Layer one is your palette — raw color values keyed by name and scale (--blue-100 through --blue-950, for example). These never appear in component CSS directly. Layer two is semantic tokens — meaning-based names that reference palette values. --color-primary, --color-surface, --color-border-subtle, --color-feedback-error.

When you switch themes, you only update the mapping between layer two and layer one. Your components never change. This is the same principle behind the color system design approach: decouple what a color *is* from what it *means* in context.

The naming convention matters. Tokens should describe *role*, not *appearance*. --color-text-inverse is correct. --color-white-text is wrong — it assumes something about the implementation that breaks in dark mode. --color-border-strong is correct. --color-gray-700 as a semantic token is wrong.

Aim for three semantic categories at minimum: surface (backgrounds and cards), content (text and icons), and interactive (buttons, links, form elements). Each category needs 4-6 tokens covering the common states: default, hover, active, disabled, subtle, and strong.

Generating Perceptually Uniform Ramps with JavaScript

Manual color picking is fine for small systems. But if you're building a component library that needs to support multiple brand themes — or you're working on something like Empire UI where you're juggling 40 visual styles — you need a programmatic approach.

The culori library is the go-to tool for color space math in JavaScript. It supports OKLCH natively and lets you interpolate, convert, and generate color ramps with a clean API. Here's how to generate a 9-step scale from a single brand hue:

import { oklch, formatCss, interpolate, samples } from 'culori';

function generateScale(hue, chroma = 0.18) {
  // 9 lightness stops from light to dark
  const lightnesses = [0.97, 0.92, 0.82, 0.70, 0.58, 0.46, 0.36, 0.26, 0.15];

  return lightnesses.map((l, i) => ({
    step: (i + 1) * 100,
    value: formatCss(oklch({ l, c: chroma * (l < 0.5 ? 0.9 : 1), h: hue }))
  }));
}

// Usage: generate brand-blue scale
const blueScale = generateScale(250);
// => [
//   { step: 100, value: 'oklch(0.97 0.18 250)' },
//   { step: 200, value: 'oklch(0.92 0.18 250)' },
//   ...
// ]

// Inject into CSS custom properties
const cssVars = blueScale
  .map(({ step, value }) => `  --blue-${step}: ${value};`)
  .join('\n');

Notice the chroma adjustment at low lightness values. Very dark colors with high chroma can fall outside the sRGB gamut on standard displays. Pulling chroma down slightly at the dark end (line 8 in the snippet) keeps colors in-gamut without a visible shift in hue. This is the kind of manual compensation you'd never think to do with HSL.

Tailwind v4 Integration: CSS-First Configuration

Tailwind v4 moved away from the JavaScript config file toward a CSS-first approach. This aligns perfectly with a semantic token system — your tokens are just CSS custom properties, and Tailwind reads them directly via @theme.

Here's how to wire your two-layer token system into Tailwind v4's @theme directive so you get utility classes like text-primary and bg-surface-subtle generated automatically:

/* design-tokens.css */
@import 'tailwindcss';

@theme {
  /* Tailwind reads these and generates utilities */
  --color-primary: oklch(0.60 0.20 250);
  --color-primary-hover: oklch(0.42 0.18 250);
  --color-primary-subtle: oklch(0.95 0.04 250);

  --color-surface: oklch(0.98 0.005 250);
  --color-surface-raised: oklch(0.96 0.008 250);
  --color-surface-overlay: oklch(0.94 0.010 250);

  --color-text-default: oklch(0.15 0.02 250);
  --color-text-muted: oklch(0.45 0.03 250);
  --color-text-inverse: oklch(0.98 0.005 250);

  --color-border-default: oklch(0.88 0.02 250);
  --color-border-strong: oklch(0.72 0.05 250);
}

/* Dark mode override — just remap semantics */
@media (prefers-color-scheme: dark) {
  :root {
    --color-primary: oklch(0.75 0.15 250);
    --color-surface: oklch(0.14 0.02 250);
    --color-text-default: oklch(0.93 0.01 250);
    --color-border-default: oklch(0.28 0.03 250);
  }
}

This generates text-primary, bg-surface, border-border-default and all the other utility classes automatically. Your components stay clean, your dark mode is a single block of CSS overrides, and adding a new theme is just another :root override block. It's the same idea behind theme toggle in React — one source of truth for the active set of values.

Accessibility and Contrast: WCAG 2.2 vs APCA

Here's the uncomfortable reality: WCAG 2.2's contrast algorithm (based on relative luminance from the 1999 sRGB spec) doesn't always predict legibility accurately. It was designed for the CRT monitors of the late 90s. Small text on mid-tone backgrounds gets a worse score than it deserves; large bold text gets inflated scores that don't reflect real-world readability.

APCA (Advanced Perceptual Contrast Algorithm) is the proposed replacement for WCAG 3.0. It accounts for font size, weight, and uses a more accurate luminance model. You can check APCA scores at www.myndex.com/APCA/. While WCAG 3.0 isn't finalized yet, running both checks during development is a good habit — especially when your OKLCH-based palette needs to clear accessibility thresholds. More detail on this in the WCAG accessibility guide.

The practical rule: for body text at 16px/400-weight, you want an APCA Lc value of at least 75. For large text (18px+ or 14px bold), Lc 60 is the minimum. Use your semantic tokens to define dedicated accessible pairs — never just grab any two tokens and assume they'll pass.

One useful pattern is to test your color ramp against white and black at generation time and annotate which steps are accessible together. If you know --blue-700 on white gives Lc 78, you can confidently use it for body copy. Document it in your Storybook component library so contributors don't have to re-derive it every time. That annotation work pays off massively at scale.

Alpha, Overlays, and the rgba Problem

Transparent colors are where color spaces get weird fast. rgba(255, 255, 255, 0.15) looks fine on a white background. On a dark navy background, that same value turns slightly grey-blue. On a vivid purple, it tints warm. The composed color shifts with every background change — which is fine if that's what you want, and a nightmare if it isn't.

OKLCH doesn't escape this problem — transparency still composites in the display color space. But using OKLCH for your base values and CSS color-mix() for alpha variants gives you much more predictable results than hand-tuning rgba values.

/* Instead of hardcoding rgba(59, 130, 246, 0.15) */
.tooltip-backdrop {
  /* color-mix with transparency — composites predictably */
  background: color-mix(in oklch, var(--color-primary) 15%, transparent);
  backdrop-filter: blur(8px);
}

.overlay-dark {
  /* Semi-transparent dark surface for modals */
  background: color-mix(in oklch, oklch(0.10 0.02 250) 80%, transparent);
}

.focus-ring {
  /* 2px offset ring with alpha — clear on any background */
  outline: 2px solid color-mix(in oklch, var(--color-primary) 70%, transparent);
  outline-offset: 2px;
}

The color-mix() function has full browser support as of early 2025. Using it with in oklch keeps the mixing math in the perceptual color space, which avoids the grey muddy tones you often get when mixing in sRGB. This pairs naturally with glassmorphism effects — if you're building frosted glass UIs, check out what is glassmorphism for the full compositing breakdown.

Shipping Tokens: From Design Files to Production CSS

The workflow question that comes up constantly: how do you keep Figma tokens in sync with your CSS? The short answer is that you don't do it manually. The longer answer involves a pipeline.

Figma's native variables export (introduced in 2024) can output JSON. Style Dictionary by Amazon takes that JSON and transforms it into platform-specific outputs — CSS custom properties, Sass variables, JS objects for React Native, whatever you need. You define transformation rules once, and token updates flow from design to code without a developer acting as the copy-paste layer. See the Figma to React guide for the full handoff workflow including variable modes.

A few things to standardize before you start: agree on a naming convention with your design team before anyone touches Figma variables. Token names are the contract between design and engineering. If your CSS says --color-primary and your Figma variable is Brand/Primary/Default, your Style Dictionary config needs a transform rule to normalize that. Get it right once and the pipeline stays clean.

Also worth noting: your spacing system and color system should use the same naming layer philosophy. Primitives at the bottom (raw values), semantics in the middle (meaning-based names), component tokens at the top (context-specific overrides like --button-bg-primary). That three-tier structure scales to enterprise-sized systems without the naming collisions that kill smaller setups.

FAQ

Can I use OKLCH in CSS today without a polyfill?

Yes. As of 2025, OKLCH is supported in Chrome 111+, Firefox 113+, and Safari 15.4+. That covers over 95% of global browser usage. You don't need a PostCSS fallback for modern web apps, though if you're targeting older enterprise environments, you can use @supports (color: oklch(0.5 0.1 250)) to progressively enhance.

How many semantic tokens does a typical design system need?

Most production systems land between 30 and 60 semantic tokens. That typically covers: 6-8 surface tokens (background levels), 4-6 content/text tokens, 6-8 interactive state tokens, 4-6 border tokens, and 8-12 feedback tokens (error, warning, success, info — each needing subtle and strong variants). Anything more than ~80 tokens usually signals you've mixed primitive palette names into your semantic layer.

What's the difference between chroma and saturation in OKLCH vs HSL?

In HSL, saturation is a percentage of the maximum possible saturation for that hue at that lightness — it's relative. In OKLCH, chroma is an absolute value (roughly 0 to 0.37 in the sRGB gamut). This means the same chroma value gives you a similar visual intensity across different hues, unlike HSL where S=80% means very different things for yellow vs blue.

Does Tailwind v4 output OKLCH by default or does it convert to hex?

Tailwind v4 uses OKLCH in its source palette definitions but outputs whatever format the browser receives — it doesn't convert. CSS custom properties with OKLCH values stay as OKLCH in your stylesheet. If you need hex fallbacks for tools that don't support custom properties (like some email clients or PDF renderers), you'll need a build step to generate static fallback values.

Should component tokens reference semantic tokens or palette tokens directly?

Always semantic tokens. A component token like --button-bg should reference var(--color-primary), not var(--blue-500). This means when you swap a theme by redefining --color-primary, every component using --button-bg updates automatically. If you wire component tokens to palette tokens, you've hard-coded the color and lost all theming flexibility.

How do I handle out-of-gamut colors when using OKLCH on wide-gamut displays?

Wide-gamut displays (P3, Rec2020) can show OKLCH colors that fall outside the sRGB gamut. Browsers handle gamut mapping automatically by default — they'll clip or map out-of-gamut values to the nearest in-gamut color. If you want explicit control, use @media (color-gamut: p3) to define higher-chroma P3 variants of your tokens alongside your standard sRGB-safe values. For most UI work, letting the browser handle gamut mapping is fine.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Multi-Brand Theming in React: One Component Library, N BrandsDark Mode in a Design System: Semantic Tokens That WorkTailwind + CSS Variables: Dynamic Theming Without JavaScriptCustomizing shadcn/ui: Colors, Radius, and Dark Mode Tokens