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.
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
CHROMAuntil 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.
Related
- Color Palette Architecture — parent section covering the full primitive-to-semantic-to-component token hierarchy
- Semantic Color Tokens for Accessibility — how to wire the primitive ramp produced here into WCAG-validated semantic aliases
- Token Fundamentals & Naming Conventions — the full naming convention system, shadow-DOM scoping rules, and
@layerarchitecture that contextualizes where primitive color tokens live