Color Systems
12 minColor Systems
Most colour palettes are built in HSL. HSL is intuitive—you pick a hue, set saturation and lightness, and you're done. The problem is that HSL's lightness axis is a lie. Two colours with identical lightness values can look dramatically different in perceived brightness. A yellow at hsl(60, 100%, 50%) and a blue at hsl(240, 100%, 50%) have the same L value but wildly different visual weight. The yellow screams; the blue whispers.
This perceptual inconsistency makes it nearly impossible to build a palette where every step feels evenly spaced. You end up hand-tuning every shade, and the result still breaks when you add a new hue.
OKLCH: perceptually uniform colour
OKLCH solves this. It's a colour space where equal numeric steps produce equal perceived changes in lightness—regardless of hue. A 10-step lightness scale in OKLCH actually looks like 10 evenly spaced steps, whether you're generating blues, yellows, or greens.
The three axes:
- L (Lightness): 0 = black, 1 = white. Unlike HSL, these steps are perceptually uniform.
- C (Chroma): How vivid the colour is. 0 = grey, higher = more saturated. The maximum depends on the hue.
- H (Hue): 0–360 degrees, same as HSL.
Building a palette with CSS variables
Define your palette as a set of CSS custom properties. Each step is a lightness level at a fixed hue:
:root {
--brand-50: oklch(0.98 0.01 250);
--brand-100: oklch(0.93 0.02 250);
--brand-200: oklch(0.87 0.04 250);
--brand-300: oklch(0.78 0.06 250);
--brand-400: oklch(0.68 0.10 250);
--brand-500: oklch(0.58 0.14 250);
--brand-600: oklch(0.48 0.14 250);
--brand-700: oklch(0.38 0.12 250);
--brand-800: oklch(0.28 0.08 250);
--brand-900: oklch(0.18 0.04 250);
}
The chroma values taper at the extremes—very light and very dark colours can't sustain high chroma without clipping. The middle of the scale carries the most saturation.
To add a second hue (a semantic colour for errors, for success), change only the hue angle and the chroma curve. The lightness steps stay the same, which means your error-red and your brand-blue will have matching visual weight at every step.
Avoiding pure black and pure white
#000000 on #FFFFFF is high contrast—21:1, the maximum. It's also harsh. On backlit screens (which is every screen), pure black text on pure white creates a vibrating edge effect that fatigues the eye over extended reading.
Soften both ends:
:root {
--text: oklch(0.15 0.01 250); /* near-black, not black */
--background: oklch(0.99 0.005 250); /* near-white, not white */
}
The result is a contrast ratio around 18:1—still far above the WCAG AA requirement of 4.5:1—but substantially more comfortable for long reading sessions. Every high-craft product does this: Linear, Stripe, Notion, Apple—none of them use pure #000 body text.
Dark mode mapping
A dark mode palette is not a light palette with the order reversed. Reversing the scale makes 50 (your lightest) the background and 900 (your darkest) the foreground—but the chroma relationships break. Dark backgrounds need lower chroma to avoid glowing, while dark-mode text needs higher lightness to maintain contrast.
The mapping pattern:
[data-theme="dark"] {
--background: oklch(0.15 0.01 250);
--text: oklch(0.92 0.01 250);
--surface: oklch(0.20 0.015 250);
--border: oklch(0.28 0.02 250);
--muted: oklch(0.55 0.03 250);
}
Key differences from a simple reversal:
- Background chroma is low. A saturated dark background creates colour noise that competes with content.
- Text lightness is high but not 1.0. Pure white text on dark backgrounds glares the same way pure black text does on light backgrounds.
- Surface sits close to background. In dark mode, elevation differences are subtle—small lightness jumps between layers, not large ones.
- Borders need more contrast.
rgba(0,0,0,0.1)borders work on light backgrounds; on dark backgrounds they disappear. Usergba(255,255,255,0.1)or OKLCH with appropriate lightness.
Colour is never the only signal
Insufficient contrast doesn't just affect users with low vision. Glare on a phone screen, a dim laptop in a meeting room, an old monitor with degraded calibration—anyone can temporarily become a low-contrast user.
More importantly, information signalled only by colour fails for users with colour-blindness, monochrome screens, and printed pages. Always pair colour with a redundant cue: an icon, a label, a shape change, a text prefix. Error fields need colour and an icon and a message. Status pills need colour and a text label. An active tab needs colour and an underline or weight change.
Common mistakes
- Building palettes in HSL and wondering why the mid-tones look uneven. Switch to OKLCH; the uniformity is immediate.
- Pure
#000text on pure#FFF. Harsh on screens. Soften both ends. - Light grey body text.
#999on#fffis 2.85:1—fails WCAG AA. Designers reach for grey to look "subtle"; the result is unreadable on phones in daylight. - Reversing the palette for dark mode. Chroma relationships break. Map dark mode independently.
- Brand colours used as link colour without contrast checks. Many brand teals and oranges fail at body text sizes against white backgrounds.