Palette Construction & Tonal Scales
Build perceptually-even, accessible color palettes using OKLCH tonal scales and semantic token architecture that scales across themes and platforms.
8 min read
The full lesson
A well-built palette is invisible. It makes every component, state, and theme work consistently — without forcing per-element color decisions every time. Most teams still reach for hex values and HSL sliders, hand-picking shades that look even but behave unpredictably. Modern color tools give us something better: mathematically consistent color spaces that let you build a tonal scale algorithmically, then map it to a semantic token layer that speaks in purpose rather than shade numbers.
Why Traditional Palettes Break Down
Here is the workflow most teams used before 2022: pick a brand hue in HSL, call it the 500, use darken() and lighten() (or tweak the L slider) to get a 100–900 range, then export as hex. It looks fine on a single screen in daylight. In practice, three problems reliably appear:
- Perceptual gaps. HSL lightness is not perceptual lightness. The gap between 700 and 800 can look larger to the eye than the same numeric gap between 200 and 300, because blue light appears darker than yellow at the same HSL-L value.
- Contrast surprises. You pick neutral-400 for placeholder text, assuming it passes WCAG AA. Then you discover that blue-400 and yellow-400 produce completely different contrast ratios against white — the hue changed the perceived brightness without touching the L value.
- Dark-mode breaks. Inverting or remapping HSL shades to a dark surface produces washed-out midtones, oversaturated darks, and blown-out lights. HSL’s axis was never calibrated for human perception across the full luminance range.
The root cause: HSL is a mathematical transformation of RGB, not a perceptual model. It was designed for code convenience, not visual uniformity.
OKLCH: The Modern Authoring Space
OKLCH (the polar form of the Oklab color space) is the recommended authoring space for palettes today. Its three axes map directly to how humans experience color:
| Axis | Meaning | Typical range |
|---|---|---|
| L | Perceptual lightness | 0 (black) to 1 (white) |
| C | Chroma (colorfulness) | 0 (gray) to ~0.4 (max sat.) |
| H | Hue angle | 0–360 degrees |
The key property: equal steps in L produce equal-looking lightness jumps, regardless of hue. A scale from L 0.15 to L 0.95 in 11 steps will feel visually even whether you build it in blue, red, or green.
OKLCH is natively supported in all modern browsers via CSS oklch(L C H) syntax. Figma surfaces it directly in its color picker. For token pipelines, libraries like culori and chroma-js can generate and convert OKLCH values in code.
Building an OKLCH Tonal Scale
A typical UI palette runs 11 stops: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950. Here is the process for a single hue — repeat it for each palette you need.
1. Fix chroma and hue, vary lightness.
Choose a brand hue angle (H) and a target chroma (C). Chroma controls how saturated the color looks. Vivid brand colors often sit around C 0.18–0.25; neutral grays are C 0.00–0.03. Keep these constant across all 11 stops. Only L changes.
2. Distribute L values linearly.
A linear spread from L 0.97 (stop 50) to L 0.12 (stop 950) works well as a starting point. Nudge the midpoint stop (500) so it achieves at least a 4.5:1 contrast ratio against white. That makes it usable as a default interactive surface color on light backgrounds.
3. Clamp chroma at the extremes.
Very light (L above 0.93) and very dark (L below 0.18) colors cannot hold high chroma without falling outside the sRGB gamut (the color range most screens can display). Your build tool should clamp these automatically to the nearest in-gamut value. In CSS, oklch() clamps silently. In token scripts, use culori’s clampChroma().
4. Audit perceptual steps.
Export the stops as swatches and squint at them. Steps that look dramatically larger or smaller than their neighbors need their L value nudged. This is rare with OKLCH but can happen at hue angles where the eye is especially sensitive — yellows and cyans in particular.
// Generate a tonal scale with culori
import { formatHex, clampChroma } from "culori";
const lightnesses = [0.97, 0.93, 0.86, 0.76, 0.63, 0.50, 0.40, 0.31, 0.24, 0.18, 0.12];
const stops = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const hue = 250; // blue-violet
const chroma = 0.20;
const scale = lightnesses.map((l, i) => {
const clamped = clampChroma({ mode: "oklch", l, c: chroma, h: hue }, "srgb");
return { stop: stops[i], hex: formatHex(clamped) };
});
Semantic Token Architecture
A raw tonal scale (blue-500, neutral-300) is a primitive layer. Primitives are the source of truth for raw color values — they should never appear directly in component code. The modern standard, codified by the W3C Design Token Community Group (DTCG), uses a three-tier model:
Primitive → Semantic → Component
Primitive tokens are the scale itself: color.blue.500, color.neutral.200. They carry no meaning beyond their raw value.
Semantic tokens assign purpose: color.surface.default, color.text.primary, color.border.interactive. These reference a primitive but expose an intent. They are what your components consume.
Component tokens are scoped overrides for a specific component: button.background.primary. These are optional — create them only when a component must deviate from the semantic layer without giving it unrestricted access to primitives.
The W3C DTCG JSON format uses $value and $type keys, with curly-brace reference syntax inside JSON strings:
{
"color": {
"surface": {
"default": { "$type": "color", "$value": "{color.neutral.50}" },
"subtle": { "$type": "color", "$value": "{color.neutral.100}" }
},
"text": {
"primary": { "$type": "color", "$value": "{color.neutral.900}" },
"secondary": { "$type": "color", "$value": "{color.neutral.600}" }
}
}
}
Tools like Style Dictionary consume this format and output CSS custom properties, Swift color assets, or Kotlin resources from a single source of truth.
Multi-Palette Systems: Neutral, Brand, Semantic, and Data
Most product design systems need at least four palette families:
Neutral / gray. The workhorse for surfaces, text, borders, and dividers. Even a faint warm or cool tint (C 0.01–0.03 in OKLCH) makes the neutral feel branded rather than generic. Material Design 3’s “tonal surface” ramp derives its neutral algorithmically from the brand hue for exactly this reason.
Brand hues. One to three hues drawn from brand guidelines, each with its own 11-stop scale. If a secondary hue sits close in chroma and lightness to the primary, check that they remain distinguishable for users with deuteranopia (red-green color deficiency), which affects roughly 8% of male users.
Semantic color groups. Success (green), warning (amber), error (red), and info (blue). These carry universal cultural meaning, and each one warrants its own tonal scale rather than borrowing from the brand palettes. Make sure the 500-level stop of each semantic group is visually distinct from the brand hues — a blue brand color should not visually conflict with the blue info state.
Data visualization palettes. These come in three varieties: categorical (varied hues with equalized C and L for visual parity), sequential (single hue where L varies), and diverging (two hues meeting at a neutral midpoint). Keep these separate from UI palettes and run their own accessibility check: every categorical color should be distinguishable in a grayscale simulation.
Contrast Checking at Scale
Build your contrast audit into the scale construction phase — not as an afterthought after components ship.
- Test each stop against white (
#fff) and near-black (approximately#0A0A0A) using a WCAG 2.2 contrast ratio tool. - Mark which stops clear 4.5:1 (normal text, AA), 3:1 (large text or UI component AA), and 7:1 (AAA).
- Your semantic token
color.text.primarymust map to a stop that clears 4.5:1 in every theme.color.text.secondarytypically targets 3:1. - Document passing stops in the token definition itself. Future maintainers can see at a glance which primitives are safe to use for text.
This approach catches regressions before they reach production. If someone reassigns color.text.secondary to a lighter stop, the audit column makes the violation visible immediately.
Do
Map semantic text tokens to primitive stops verified against every surface they will appear on. Record the contrast ratio in the token metadata so designers and engineers can audit at a glance without rerunning tools.
Don't
Use mid-scale primitive stops (like neutral-400) for body text because they look subtle enough. Perceived lightness varies across monitors and viewing conditions; documented contrast ratios do not.
Dark Mode as a First-Class Theme
Dark mode is not a CSS invert filter applied to your light palette. It is a parallel semantic token mapping that points different primitives at the same semantic names. Because all components reference semantic tokens, you switch the entire visual theme by updating one mapping file — no component code changes needed.
Key principles for dark theme palette mapping:
- Base surface on near-black, not pure black.
oklch(0.07 0.01 250)(approximately#0A0B10) leaves room for luminance-step elevation and reduces eye strain compared to pure#000000. - Elevation uses luminance steps, not shadows. On dark surfaces, higher layers should be lighter (higher L). Replace drop shadows with L-step fills:
surface.defaultat L 0.07,surface.raisedat L 0.11,surface.overlayat L 0.15. - Desaturate slightly at extremes. Vivid foreground colors vibrate on dark backgrounds. Lower C slightly for interactive foreground colors in dark mode — they still read as the correct hue, but they feel calmer and cause less eye strain.
- Re-audit contrast. A stop that passes WCAG AA on white may fail on your dark surface. Run the full contrast audit again for each theme.
/* Light theme */
:root {
--color-surface-default: oklch(0.97 0.005 250);
--color-text-primary: oklch(0.15 0.01 250);
--color-action-default: oklch(0.40 0.20 250);
}
/* Dark theme */
[data-theme="dark"] {
--color-surface-default: oklch(0.07 0.01 250);
--color-text-primary: oklch(0.93 0.005 250);
--color-action-default: oklch(0.65 0.17 250);
}
Common Mistakes to Avoid
Over-scaling. More stops means more decision surface, not more flexibility. 11 stops per hue is the practical ceiling for most teams. Beyond that, designers start reaching for arbitrary stops and the scale loses its coherence.
Semantic proliferation. Don’t create a semantic token for every conceivable component attribute. Start with surface, text, border, and interactive as your semantic categories. Add component-scoped tokens only when a component genuinely needs to deviate — not preemptively.
Forgetting alpha. Semi-transparent tokens — overlays, scrim layers, disabled states — behave differently in dark mode because the underlying surface color bleeds through. Maintain alpha values as semantic tokens (for example, color.overlay.scrim remapped per theme) rather than hardcoding a single rgba value that only works on light backgrounds.
Treating APCA as a compliance standard. APCA is a perceptual lens that helps catch visually marginal choices, but it is not adopted in WCAG 3.0 and carries no legal standing. Use WCAG 2.2 AA as your baseline. Use APCA optionally to flag cases where a color technically passes the ratio but looks low-contrast in context.