Home / Articles / Color & Theming /
Building design-system palettes in OKLCH
A systematic approach to building color scales, semantic tokens, and multi-hue palettes using OKLCH's perceptual uniformity.
Why OKLCH changes palette design
Design systems built on hex or HSL suffer from a core problem: lightness values are unreliable across hues. A nine-step blue scale and a nine-step yellow scale will appear to have different step sizes even when the math is identical. Designers compensate by hand-tweaking each swatch, and the result is fragile — change one color, and the whole scale needs manual adjustment.
OKLCH eliminates this problem. Because lightness is perceptually linear, you can apply the same L values across every hue and get visually consistent results. This means one formula generates every color scale in your system.
/* The same lightness steps applied to two different hues
produce scales that feel equally spaced */
/* Blue scale (hue 265) */
--blue-100: oklch(0.95 0.04 265);
--blue-500: oklch(0.60 0.20 265);
--blue-900: oklch(0.25 0.10 265);
/* Green scale (hue 150) — same L values */
--green-100: oklch(0.95 0.04 150);
--green-500: oklch(0.60 0.20 150);
--green-900: oklch(0.25 0.10 150);
Defining a nine-step primary scale
A nine-step scale (100 through 900) covers most design system needs. The key is spacing lightness evenly and tapering chroma at the extremes, since very light and very dark colors cannot hold much saturation.
:root {
/* Primary blue — hue 265 */
--primary-100: oklch(0.95 0.03 265);
--primary-200: oklch(0.88 0.07 265);
--primary-300: oklch(0.79 0.12 265);
--primary-400: oklch(0.70 0.17 265);
--primary-500: oklch(0.60 0.20 265); /* base */
--primary-600: oklch(0.50 0.20 265);
--primary-700: oklch(0.40 0.18 265);
--primary-800: oklch(0.30 0.14 265);
--primary-900: oklch(0.22 0.09 265);
}
/* Lightness pattern: 0.95 → 0.22 (uniform ~0.09 steps)
Chroma pattern: low at edges (0.03, 0.09),
peaks at mid-range (0.20) */
The chroma curve is intentional. At lightness 0.95, a chroma of 0.20 would push the color outside the sRGB gamut for most hues. Tapering chroma at the extremes keeps every swatch displayable.
Generating scales for any hue
Once you have a lightness and chroma template, generating a new color scale is a matter of changing the hue value. You can encode this as a reusable pattern with custom properties:
:root {
/* Hue tokens — change one value to re-theme */
--hue-primary: 265;
--hue-success: 150;
--hue-warning: 75;
--hue-danger: 25;
/* Primary scale */
--primary-100: oklch(0.95 0.03 var(--hue-primary));
--primary-300: oklch(0.79 0.12 var(--hue-primary));
--primary-500: oklch(0.60 0.20 var(--hue-primary));
--primary-700: oklch(0.40 0.18 var(--hue-primary));
--primary-900: oklch(0.22 0.09 var(--hue-primary));
/* Success scale — same L and C template, different hue */
--success-100: oklch(0.95 0.03 var(--hue-success));
--success-300: oklch(0.79 0.12 var(--hue-success));
--success-500: oklch(0.60 0.20 var(--hue-success));
--success-700: oklch(0.40 0.18 var(--hue-success));
--success-900: oklch(0.22 0.09 var(--hue-success));
}
To re-brand the entire system, change --hue-primary. Every scale that references it updates automatically. This approach works in vanilla CSS with no preprocessor required.
Semantic tokens: bridging scales and components
Raw scale values like --primary-500 describe what a color is. Semantic tokens describe what a color does. This separation lets you swap themes (like light/dark mode) without touching component code.
/* Semantic tokens for dark mode */
:root {
/* Backgrounds */
--color-bg: oklch(0.13 0.02 260);
--color-bg-raised: oklch(0.19 0.02 260);
--color-bg-overlay: oklch(0.10 0.02 260 / 0.85);
/* Text */
--color-text: oklch(0.93 0.01 260);
--color-text-muted: oklch(0.63 0.02 260);
--color-text-accent: var(--primary-400);
/* Interactive */
--color-action: var(--primary-500);
--color-action-hover: var(--primary-400);
--color-action-text: oklch(0.98 0.01 265);
/* Feedback */
--color-success: var(--success-500);
--color-danger: oklch(0.60 0.20 25);
--color-warning: oklch(0.75 0.16 75);
}
Components reference only semantic tokens: background: var(--color-bg-raised). Swapping to a light theme means reassigning semantic tokens — the component CSS stays untouched.
Accessibility: contrast pairs from the scale
OKLCH lightness directly correlates to perceived brightness, which makes checking contrast easier. A rough rule of thumb: a lightness difference of 0.4 or more between text and background usually meets WCAG AA (4.5:1) for normal text.
/* Accessible pairings from the primary scale */
/* AA large text: ΔL ≥ 0.3 */
.badge {
background: oklch(0.60 0.20 265); /* primary-500, L=0.60 */
color: oklch(0.95 0.03 265); /* primary-100, L=0.95 */
/* ΔL = 0.35 — passes AA for large text */
}
/* AA normal text: ΔL ≥ 0.4+ */
.card {
background: oklch(0.19 0.02 260); /* L=0.19 */
color: oklch(0.93 0.01 260); /* L=0.93 */
/* ΔL = 0.74 — easily passes AAA */
}
/* Danger on dark: ensure enough contrast */
.alert--danger {
background: oklch(0.25 0.08 25); /* dark red bg, L=0.25 */
color: oklch(0.90 0.05 25); /* light red text, L=0.90 */
/* ΔL = 0.65 — passes AA */
border-left: 4px solid oklch(0.60 0.20 25);
}
Always verify with a proper contrast checker (the APCA algorithm is even better for modern use), but OKLCH lightness gives you a reliable first estimate that hex and HSL cannot.
Neutrals: the overlooked scale
Most design systems need a neutral gray scale for backgrounds, borders, and secondary text. In OKLCH, a neutral is simply chroma at or near zero. Adding a tiny bit of chroma (0.01–0.03) in your brand hue creates "tinted neutrals" that feel more cohesive.
:root {
/* Tinted neutrals — hue 260 matches the brand blue */
--gray-50: oklch(0.97 0.005 260);
--gray-100: oklch(0.93 0.01 260);
--gray-200: oklch(0.85 0.01 260);
--gray-300: oklch(0.75 0.015 260);
--gray-400: oklch(0.63 0.02 260);
--gray-500: oklch(0.53 0.02 260);
--gray-600: oklch(0.43 0.02 260);
--gray-700: oklch(0.33 0.02 260);
--gray-800: oklch(0.23 0.02 260);
--gray-900: oklch(0.15 0.02 260);
--gray-950: oklch(0.10 0.02 260);
}
/* These are barely blue — C at 0.01–0.02 reads as gray
but feels warmer and more intentional than pure gray */
The same approach works for warm neutrals (hue ~60) or cool neutrals (hue ~220). The tiny chroma value is invisible as a color but contributes to the overall feel of the interface.
Putting it all together
Here is a minimal but complete palette structure for a design system. It includes two hue scales, tinted neutrals, and semantic tokens.
:root {
/* === Palette scales === */
--hue-brand: 265;
--hue-accent: 170;
/* Brand: 5 stops */
--brand-100: oklch(0.95 0.03 var(--hue-brand));
--brand-300: oklch(0.79 0.12 var(--hue-brand));
--brand-500: oklch(0.60 0.20 var(--hue-brand));
--brand-700: oklch(0.40 0.18 var(--hue-brand));
--brand-900: oklch(0.22 0.09 var(--hue-brand));
/* Accent: 3 stops */
--accent-200: oklch(0.85 0.08 var(--hue-accent));
--accent-500: oklch(0.60 0.18 var(--hue-accent));
--accent-800: oklch(0.30 0.12 var(--hue-accent));
/* === Semantic tokens === */
--text-primary: oklch(0.93 0.01 260);
--text-secondary: oklch(0.63 0.02 260);
--surface: oklch(0.13 0.02 260);
--surface-raised: oklch(0.19 0.02 260);
--border: oklch(0.30 0.03 260);
--interactive: var(--brand-500);
}
This structure is small enough to maintain by hand, systematic enough to scale, and perceptually consistent thanks to OKLCH. When you need more stops, interpolate between existing ones following the same lightness pattern.