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 Link to this section

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 Link to this section

Equal lightness steps in HSL compared with OKLCH Two four-swatch ramps. The HSL row uses equal nominal lightness steps that read as uneven, while the OKLCH row uses equal L steps that read as evenly spaced. Same nominal lightness step, two colour spaces HSL — lightness is nominal, perceived brightness is not L 90% L 70% L 50% L 30% uneven gaps OKLCH — equal L steps read as equal steps L 0.90 L 0.75 L 0.60 L 0.45 even gaps The practical consequence: an OKLCH ramp can be generated by formula and still pass contrast checks at predictable steps, while an HSL ramp needs hand correction at every hue.
Perceptual uniformity is what makes a ramp generatable. In OKLCH the same numeric step means the same visual step, at every hue.

Step 1 — Pick your fixed hue and chroma Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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

Verifying a generated OKLCH ramp for gamut and contrast A three-stage verification flow followed by paired panels describing the out-of-gamut symptom, where adjacent steps collapse to the same colour, and the corresponding fix. generate ramp L from 0.98 to 0.22 check gamut clip chroma into sRGB check contrast each step against both ends Out-of-gamut symptom two adjacent steps render identically because both clipped to the same sRGB edge most visible in saturated yellows and cyans Fix reduce chroma as lightness approaches the ends or ship the ramp in oklch() with an sRGB fallback verify by diffing computed values, not source
Two ramp steps that render identically are the signature of gamut clipping — check computed values rather than the source declarations.

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 Link to this section

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 Link to this section

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.