Building Perceptually Uniform Color Scales with OKLCH

Part of Color Palette Architecture. This page walks through generating a primitive color ramp (steps 50–900) in the oklch() color space so every step is perceptually equidistant, lightness is mechanically predictable, and the output is emitted as typed primitive tokens.

OKLCH vs HSL lightness ramp comparison Two rows of nine color swatches comparing an HSL ramp with uneven perceptual steps against an OKLCH ramp with uniform perceptual steps, annotated with L values and DeltaE gaps. HSL ramp OKLCH ramp L 95% L 85% L 72% L 59% L 47% L 39% L 28% L 18% L 9% ΔE steps: uneven — large jumps in mid-tones L .96 L .88 L .79 L .69 L .59 L .49 L .39 L .29 L .19 ΔE steps: ~11 per interval — mechanically even HSL: lightness % ≠ perceptual brightness OKLCH: L channel = perceptual lightness
OKLCH ramp (bottom) produces mechanically even perceptual steps across the 50–800 range; HSL (top) compresses mid-tone differences and misrepresents apparent brightness.

Prerequisites

Before generating the ramp you need these in place:

  • A token structure with a primitive tier separate from semantic aliases — the output of this workflow feeds that tier, not component tokens. See Token Fundamentals & Naming Conventions for the three-tier model.
  • Node.js 18+ (for the generator script) or a browser DevTools console (for quick checks).
  • A modern browser for testing: Chrome 111+, Firefox 113+, Safari 15.4+ all ship oklch() natively.
  • A target hue angle and starting chroma value for your brand color. These two values stay fixed across the whole ramp — only lightness moves.
  • Understanding of when to use @supports — required for Safari 15.3 and below and any legacy Chromium you must support.

Step-by-step Implementation

Step 1 — Pick your fixed hue and chroma

Identify your brand’s primary hue in OKLCH. Open DevTools, type oklch(0.5 0.18 295) into the color picker, and nudge the hue angle until it matches your brand swatch. Write down H (hue, 0–360) and C (chroma, typically 0.10–0.22 for saturated colors).

// Design decision — record these two constants in your token source-of-truth
const HUE    = 295;   // violet-purple
const CHROMA = 0.18;  // saturated but gamut-safe on most steps

Why this works: OKLCH is a cylindrical form of the OKLab perceptual color space. Hue and chroma are independent of lightness, so fixing them while varying L produces a ramp that looks like it came from the same family rather than drifting toward gray or gaining unexpected warmth — a common failure mode with HSL.

Step 2 — Define the lightness stops

The nine standard stops (50 through 900) map to evenly spaced lightness values from 0.97 down to 0.15. In OKLCH, L runs from 0 (black) to 1 (white) and is perceptually linear, so equal numeric steps produce roughly equal visual jumps.

const STOPS = {
  50:  0.97,
  100: 0.93,
  200: 0.84,
  300: 0.73,
  400: 0.62,
  500: 0.51,
  600: 0.41,
  700: 0.31,
  800: 0.21,
  900: 0.13,
};

Why this works: The gaps between steps are not perfectly linear because human sensitivity to lightness is not fully linear even in OKLab — the upper range compresses slightly. The values above are calibrated so that DeltaE 2000 (ΔE2000) differences between adjacent stops land within a ±2 band of each other, giving you a ramp where no single jump looks disproportionately large or small.

Step 3 — Generate OKLCH declarations and hex fallbacks

This Node.js script produces two outputs per stop: the oklch() value for modern browsers and a hex fallback computed via CSS Color Level 4’s gamut-mapping algorithm (approximated here using the culori library).

import { oklch, formatHex, clampChroma } from "culori";

const HUE    = 295;
const CHROMA = 0.18;

const STOPS = {
  50: 0.97, 100: 0.93, 200: 0.84, 300: 0.73, 400: 0.62,
  500: 0.51, 600: 0.41, 700: 0.31, 800: 0.21, 900: 0.13,
};

const lines = Object.entries(STOPS).map(([step, l]) => {
  // clampChroma maps the oklch value into sRGB gamut before converting to hex
  const clamped = clampChroma({ mode: "oklch", l, c: CHROMA, h: HUE }, "oklch", "srgb");
  const hex     = formatHex(clamped);
  return [
    `  /* step-${step}: oklch(${l} ${CHROMA} ${HUE}) */`,
    `  --color-violet-${step}: ${hex};`,
    `  --color-violet-${step}-oklch: oklch(${l} ${CHROMA} ${HUE});`,
  ].join("\n");
});

console.log(`:root {\n${lines.join("\n")}\n}`);

Run with:

node --input-type=module generate-ramp.mjs

Why this works: clampChroma from culori implements the CSS Color 4 gamut-mapping spec: it bisects the chroma axis until the color fits inside sRGB without clipping individual channels. Naive channel-clipping produces a different hue shift than spec-compliant clamping — especially visible in the 400–600 range where chroma is highest.

Step 4 — Emit as primitive tokens with @supports fallback

Structure the output so that browsers without oklch() support receive the hex value, and browsers that do get the perceptually accurate version.

@layer design-tokens.primitives {
  :root {
    /* step-50: oklch(0.97 0.18 295) */
    --color-violet-50: #f5f2fe;
    --color-violet-100: #ede8fd;
    --color-violet-200: #d5cbfa;
    --color-violet-300: #b9a6f5;
    --color-violet-400: #9b7dee;
    --color-violet-500: #7c55e0;
    --color-violet-600: #5e33c4;
    --color-violet-700: #41209e;
    --color-violet-800: #281272;
    --color-violet-900: #140847;
  }

  @supports (color: oklch(0 0 0)) {
    :root {
      --color-violet-50:  oklch(0.97 0.18 295);
      --color-violet-100: oklch(0.93 0.18 295);
      --color-violet-200: oklch(0.84 0.18 295);
      --color-violet-300: oklch(0.73 0.18 295);
      --color-violet-400: oklch(0.62 0.18 295);
      --color-violet-500: oklch(0.51 0.18 295);
      --color-violet-600: oklch(0.41 0.18 295);
      --color-violet-700: oklch(0.31 0.18 295);
      --color-violet-800: oklch(0.21 0.18 295);
      --color-violet-900: oklch(0.13 0.18 295);
    }
  }
}

Why this works: CSS custom properties resolve at use-time, but the @supports block overrides the :root declarations only in browsers that parse oklch(). Browsers that skip the block still get the hex fallbacks defined above it — without any JavaScript and without a build-time conditional. Placing everything inside @layer design-tokens.primitives keeps specificity at its lowest predictable value.

Step 5 — Handle out-of-gamut stops

High chroma at middle lightness values (steps 400–600) often exceeds the sRGB gamut, particularly on violet and cyan hues. You must decide whether to gamut-clip or reduce chroma on those stops.

import { oklch, formatHex, clampChroma, inGamut } from "culori";

const inSRGB = inGamut("rgb");

Object.entries(STOPS).forEach(([step, l]) => {
  const raw     = { mode: "oklch", l, c: CHROMA, h: HUE };
  const gamutOk = inSRGB(raw);

  if (!gamutOk) {
    const safe = clampChroma(raw, "oklch", "srgb");
    console.warn(
      `step-${step}: out of sRGB gamut. Clamped chroma ${CHROMA}${safe.c.toFixed(4)}`
    );
  }
});

When a stop is out of gamut, you have two options:

  • Reduce chroma globally — lower CHROMA until the worst offender (typically step 500) fits. This keeps the ramp mathematically consistent but makes all steps slightly less saturated.
  • Per-step chroma reduction — allow higher chroma on lighter and darker stops while clamping only the mid-tone stops. This is harder to maintain but produces more vibrant endpoints.

Why this works: Browsers that support Display P3 (color(display-p3 ...)) can render colors outside sRGB, so if your target includes Safari on Apple hardware, you can keep the original oklch() declaration and let the browser map it to P3. Add a P3 fallback layer if you need the wider gamut explicitly.

Step 6 — Reference primitives from semantic tokens

The ramp values are primitive tokens — they should never be applied directly to components. Wire them into your semantic color token layer before any component consumes them.

@layer design-tokens.semantic {
  :root {
    --color-action-primary:         var(--color-violet-600);
    --color-action-primary-hover:   var(--color-violet-700);
    --color-action-primary-subtle:  var(--color-violet-100);
    --color-action-on-primary:      var(--color-violet-50);
  }
}

Why this works: The two-layer approach means you can regenerate the entire primitive ramp (changing only HUE, CHROMA, or the STOPS table) without touching any semantic or component token. This is the essential maintenance advantage over hand-picked hex ramps.

Step 7 — Export to JSON for Style Dictionary

If your pipeline uses Style Dictionary or a similar compiler, emit primitives in the W3C Design Token Community Group format so they can be consumed by any tool.

{
  "color": {
    "violet": {
      "50":  { "$value": "oklch(0.97 0.18 295)", "$type": "color", "_hex": "#f5f2fe" },
      "100": { "$value": "oklch(0.93 0.18 295)", "$type": "color", "_hex": "#ede8fd" },
      "200": { "$value": "oklch(0.84 0.18 295)", "$type": "color", "_hex": "#d5cbfa" },
      "300": { "$value": "oklch(0.73 0.18 295)", "$type": "color", "_hex": "#b9a6f5" },
      "400": { "$value": "oklch(0.62 0.18 295)", "$type": "color", "_hex": "#9b7dee" },
      "500": { "$value": "oklch(0.51 0.18 295)", "$type": "color", "_hex": "#7c55e0" },
      "600": { "$value": "oklch(0.41 0.18 295)", "$type": "color", "_hex": "#5e33c4" },
      "700": { "$value": "oklch(0.31 0.18 295)", "$type": "color", "_hex": "#41209e" },
      "800": { "$value": "oklch(0.21 0.18 295)", "$type": "color", "_hex": "#281272" },
      "900": { "$value": "oklch(0.13 0.18 295)", "$type": "color", "_hex": "#140847" }
    }
  }
}

The _hex field is a non-standard extension for your fallback transform. Add a Style Dictionary transform that reads _hex and outputs it first in the CSS block, before the @supports override.

Why this works: The $value field stores the canonical oklch() value, so tooling that understands Color Level 4 gets full fidelity. The _hex field gives a Style Dictionary transform a guaranteed-sRGB anchor without requiring a runtime color library in the build.

Verification

After generating the ramp, confirm two things: contrast progression is predictable across stops, and perceptual steps are even.

Contrast check — Steps 600–900 on white background should meet WCAG AA (4.5:1) for text use. Run this in Node:

import { wcagContrast, parse } from "culori";

const white  = parse("#ffffff");
const stops  = [
  ["600", "#5e33c4"], ["700", "#41209e"],
  ["800", "#281272"], ["900", "#140847"],
];

stops.forEach(([step, hex]) => {
  const ratio = wcagContrast(white, parse(hex));
  console.log(`violet-${step} on white: ${ratio.toFixed(2)}:1`);
});

Expected output — all values ≥ 4.5:

violet-600 on white: 5.12:1
violet-700 on white: 8.44:1
violet-800 on white: 13.1:1
violet-900 on white: 17.8:1

ΔE2000 step uniformity — Use culori’s differenceEuclidean (or a proper ΔE2000 implementation) to confirm adjacent steps differ by no more than ±3 ΔE units from each other:

import { differenceEuclidean, oklch } from "culori";

const diff = differenceEuclidean("oklch");
const ls   = [0.97, 0.93, 0.84, 0.73, 0.62, 0.51, 0.41, 0.31, 0.21, 0.13];

ls.forEach((l, i) => {
  if (i === 0) return;
  const a = { mode: "oklch", l: ls[i - 1], c: 0.18, h: 295 };
  const b = { mode: "oklch", l,             c: 0.18, h: 295 };
  console.log(`step ${i}: ΔE = ${diff(a, b).toFixed(2)}`);
});

If any step produces a ΔE above 15 or below 8, adjust the STOPS table until the spread normalizes. OKLCH’s L scale is a good proxy but not identical to ΔE2000 — small manual corrections to the table are normal and expected.

Troubleshooting

Symptom Likely Cause Fix
Mid-tone steps (400–500) look muddy or grayish in the browser Chroma was silently clamped by the browser to fit sRGB, reducing saturation unevenly Lower CHROMA to 0.14–0.15 so no stop is out of gamut, or emit explicit P3 declarations alongside the oklch values
Colors look correct in Chrome but washed out in Safari 15.3 Safari 15.3 does not support oklch() and the @supports block is not engaged, but the hex fallbacks are not being loaded Verify that hex declarations appear before the @supports block in the compiled CSS; a CSS minifier that reorders rules will break this
Step 900 is indistinguishable from black L = 0.13 at high chroma clips to very dark sRGB values; the chroma contribution becomes invisible Reduce chroma on the darkest two steps: use C = 0.10 at step 800 and C = 0.07 at step 900
The ramp has a visible hue shift at step 300–400 Some hues (especially green around H = 145 and yellow around H = 100) have inherent lightness in OKLab that causes apparent hue drift when L crosses 0.65 Offset hue by 3–5° on the affected steps, or accept the shift and pick a different starting hue that stays stable across the lightness range
Tokens don’t resolve in Shadow DOM components CSS custom properties declared on :root do not pierce Shadow DOM when the host uses closed mode Redeclare the primitive layer on :host in the component stylesheet, or use @layer with @property inheritance — see the Token Fundamentals & Naming Conventions guide for shadow-DOM scoping patterns

Migration Note

If you have an existing hand-picked hex ramp, replace it in three phases to avoid a sudden visual shift:

Phase 1 — Audit. Map each existing hex to its nearest OKLCH equivalent using culori.nearest. Record where lightness values are clustered (often the 400–600 range is over-represented in hand-picked palettes).

Phase 2 — Parallel emit. Add the new OKLCH ramp under new token names (--color-violet-*) alongside the old names (--legacy-violet-*). Keep the old names resolving to the old hex values. Ship the change behind a feature flag or scoped to a new @layer.

@layer design-tokens.primitives {
  :root {
    /* legacy names preserved during migration */
    --legacy-violet-base: #6d28d9;
    --legacy-violet-light: #ede9fe;

    /* new perceptually uniform ramp */
    --color-violet-500: oklch(0.51 0.18 295);
    --color-violet-100: oklch(0.93 0.18 295);
  }
}

Phase 3 — Component-by-component cutover. Update semantic tokens one family at a time (action colors first, then status, then surface). After each semantic group migrates, verify contrast ratios still pass, then deprecate the legacy primitive. This staged approach avoids a big-bang diff that is hard to review and nearly impossible to revert safely.

Teams with more than 200 component stylesheets should automate the component cutover with a PostCSS plugin that rewrites var(--legacy-violet-*) references to their new --color-violet-* equivalents once the semantic mapping is confirmed.