Color management in web applications has historically been defined in color spaces that don’t match how humans perceive color. HSL, the most common CSS color model, has a critical flaw: two colors with the same lightness value (e.g., hsl(60, 100%, 50%) yellow and hsl(240, 100%, 50%) blue) look dramatically different in perceived brightness. This makes it impossible to create a consistent palette by simply stepping through hue values at fixed saturation and lightness. OKLCH (Lightness, Chroma, Hue in the Oklab perceptual color space) fixes this by ensuring that equal lightness values produce colors that actually look equally bright, making it the ideal color model for design systems.
A typical design system defines its complete color palette as CSS custom properties in OKLCH. Every semantic color — backgrounds, text, borders, accents, success/warning/error states, and chart colors — is defined as an OKLCH token. Dark mode is implemented as a synchronized set of alternative token values on a [data-theme="dark"] selector, where the same semantic token name maps to a different OKLCH value tuned for dark backgrounds. Downstream applications import these tokens and use semantic names like var(--color-surface-elevated) rather than raw color values, ensuring visual consistency across the entire product suite.
The design token system goes beyond just colors — it defines spacing scales, border radii, shadows, and typography tokens — but the OKLCH color tokens are the most technically interesting because they leverage the perceptual uniformity of the Oklab color space to generate harmonious palettes programmatically. A palette generation script produces shade ramps (50-950) for each brand hue by stepping lightness in OKLCH while keeping chroma and hue constant, guaranteeing that each step looks like an even brightness increment to the human eye.
OKLCH defines colors with three axes: L (lightness, 0-1 from black to white), C (chroma, 0-0.4 from gray to maximally vivid), and H (hue, 0-360 degrees around the color wheel). The “OK” prefix indicates it’s based on the Oklab perceptual color space designed by Björn Ottosson, where Euclidean distance between two colors correlates with how different they look to humans. CSS natively supports OKLCH via oklch(L C H) syntax in all modern browsers, making it a practical choice for production design tokens.
In a perceptually uniform color space, a 10% change in lightness at hue=60 (yellow) looks the same as a 10% change in lightness at hue=240 (blue). HSL is not perceptually uniform — HSL yellow at 50% lightness appears much brighter than HSL blue at 50% lightness. OKLCH’s uniformity means you can generate palette ramps algorithmically (step lightness from 0.95 to 0.15 in equal increments) and every ramp will look like a consistent gradient, regardless of the base hue. This eliminates the manual per-hue tweaking required with HSL-based palettes.
Design tokens are named, semantic variables that abstract raw values (colors, sizes, durations) behind meaningful names. Instead of color: oklch(0.85 0.15 264) sprinkled through component styles, you write color: var(--color-accent). The token layer decouples the design intent (“accent color”) from the design decision (“this specific shade of indigo”), making it possible to change the entire palette by updating one file rather than finding and replacing values across hundreds of components.
The token system has two layers. Primitive tokens define the raw palette: --indigo-500: oklch(0.55 0.25 264). Semantic tokens reference primitives with intent: --color-accent: var(--indigo-500). Components only use semantic tokens. This two-layer structure means you can swap the accent color from indigo to teal by changing one semantic token assignment, and you can create entirely new themes (high contrast, colorblind-safe) by reassigning semantic tokens to different primitives.
Rather than sprinkling @media (prefers-color-scheme: dark) throughout component styles, dark mode is implemented by redefining semantic token values under a [data-theme="dark"] selector. A button styled with background: var(--color-surface-elevated) automatically gets the correct background in both themes because the token’s value changes — the component’s CSS never changes. This makes dark mode a property of the token system, not a per-component concern.
The primitive layer defines shade ramps for each brand hue. Each shade is an OKLCH triplet where lightness decreases in perceptually uniform steps while chroma and hue remain constant (or near-constant — chroma is slightly reduced at the extremes to stay within the sRGB gamut).
/* primitives.css — raw OKLCH palette */
:root {
/* Indigo ramp — brand primary */
--indigo-50: oklch(0.97 0.02 264);
--indigo-100: oklch(0.93 0.05 264);
--indigo-200: oklch(0.85 0.10 264);
--indigo-300: oklch(0.75 0.16 264);
--indigo-400: oklch(0.65 0.22 264);
--indigo-500: oklch(0.55 0.25 264);
--indigo-600: oklch(0.48 0.23 264);
--indigo-700: oklch(0.40 0.20 264);
--indigo-800: oklch(0.32 0.16 264);
--indigo-900: oklch(0.25 0.12 264);
--indigo-950: oklch(0.18 0.08 264);
/* Teal ramp — secondary accent */
--teal-50: oklch(0.97 0.02 175);
--teal-100: oklch(0.93 0.05 175);
--teal-200: oklch(0.85 0.10 175);
--teal-300: oklch(0.75 0.14 175);
--teal-400: oklch(0.65 0.16 175);
--teal-500: oklch(0.58 0.15 175);
--teal-600: oklch(0.50 0.13 175);
--teal-700: oklch(0.42 0.11 175);
--teal-800: oklch(0.34 0.08 175);
--teal-900: oklch(0.26 0.06 175);
--teal-950: oklch(0.19 0.04 175);
/* Neutral ramp — slate with slight blue hue */
--neutral-50: oklch(0.98 0.005 264);
--neutral-100: oklch(0.94 0.008 264);
--neutral-200: oklch(0.88 0.010 264);
--neutral-300: oklch(0.78 0.012 264);
--neutral-400: oklch(0.62 0.012 264);
--neutral-500: oklch(0.50 0.012 264);
--neutral-600: oklch(0.40 0.012 264);
--neutral-700: oklch(0.30 0.015 264);
--neutral-800: oklch(0.22 0.018 264);
--neutral-900: oklch(0.16 0.020 264);
--neutral-950: oklch(0.12 0.022 264);
/* Semantic status colors */
--red-500: oklch(0.55 0.22 25);
--amber-500: oklch(0.75 0.18 75);
--green-500: oklch(0.62 0.19 145);
}
Semantic tokens map design intent to primitive values. The dark theme reassigns these tokens so components automatically adapt.
/* tokens.css — semantic layer */
:root, [data-theme="light"] {
/* Surfaces */
--color-bg: var(--neutral-50);
--color-surface: white;
--color-surface-elevated: var(--neutral-100);
--color-surface-sunken: var(--neutral-200);
/* Text */
--color-text: var(--neutral-900);
--color-text-secondary: var(--neutral-600);
--color-text-muted: var(--neutral-400);
/* Borders */
--color-border: var(--neutral-200);
--color-border-strong: var(--neutral-300);
/* Accents */
--color-accent: var(--indigo-500);
--color-accent-hover: var(--indigo-600);
--color-accent-subtle: var(--indigo-100);
--color-secondary: var(--teal-500);
/* Status */
--color-success: var(--green-500);
--color-warning: var(--amber-500);
--color-danger: var(--red-500);
/* Charts — perceptually distinct hues at equal lightness */
--color-chart-1: oklch(0.65 0.20 264); /* indigo */
--color-chart-2: oklch(0.65 0.16 175); /* teal */
--color-chart-3: oklch(0.65 0.18 25); /* coral */
--color-chart-4: oklch(0.65 0.15 135); /* green */
--color-chart-5: oklch(0.65 0.20 310); /* purple */
/* Radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
}
[data-theme="dark"] {
/* Surfaces — inverted lightness */
--color-bg: var(--neutral-950);
--color-surface: var(--neutral-900);
--color-surface-elevated: var(--neutral-800);
--color-surface-sunken: var(--neutral-950);
/* Text — lighter values for dark bg */
--color-text: var(--neutral-100);
--color-text-secondary: var(--neutral-400);
--color-text-muted: var(--neutral-600);
/* Borders — darker values */
--color-border: var(--neutral-800);
--color-border-strong: var(--neutral-700);
/* Accents — slightly lighter for contrast on dark bg */
--color-accent: var(--indigo-400);
--color-accent-hover: var(--indigo-300);
--color-accent-subtle: var(--indigo-900);
--color-secondary: var(--teal-400);
/* Charts — bump lightness for dark bg readability */
--color-chart-1: oklch(0.72 0.20 264);
--color-chart-2: oklch(0.72 0.16 175);
--color-chart-3: oklch(0.72 0.18 25);
--color-chart-4: oklch(0.72 0.15 135);
--color-chart-5: oklch(0.72 0.20 310);
}
The shade ramps are generated programmatically by stepping lightness in OKLCH. The script also clamps chroma at the extremes to avoid out-of-gamut colors that would be silently clipped by browsers.
// generate-palette.ts — produces OKLCH shade ramps
interface PaletteConfig {
name: string;
hue: number;
peakChroma: number; // chroma at mid-lightness
}
const SHADE_STOPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const LIGHTNESS_MAP: Record<number, number> = {
50: 0.97, 100: 0.93, 200: 0.85, 300: 0.75, 400: 0.65,
500: 0.55, 600: 0.48, 700: 0.40, 800: 0.32, 900: 0.25, 950: 0.18,
};
function chromaForLightness(L: number, peakChroma: number): number {
// Bell curve: peak chroma at L=0.55, reduced at extremes
// to stay within sRGB gamut
const center = 0.55;
const spread = 0.35;
const t = Math.exp(-((L - center) ** 2) / (2 * spread ** 2));
return peakChroma * t;
}
function generateRamp(config: PaletteConfig): string {
return SHADE_STOPS.map(stop => {
const L = LIGHTNESS_MAP[stop];
const C = chromaForLightness(L, config.peakChroma).toFixed(3);
return ` --${config.name}-${stop}: oklch(${L} ${C} ${config.hue});`;
}).join('\n');
}
// Generate all brand ramps
const palettes: PaletteConfig[] = [
{ name: 'indigo', hue: 264, peakChroma: 0.25 },
{ name: 'teal', hue: 175, peakChroma: 0.16 },
{ name: 'red', hue: 25, peakChroma: 0.22 },
{ name: 'amber', hue: 75, peakChroma: 0.18 },
{ name: 'green', hue: 145, peakChroma: 0.19 },
];
const css = `:root {\n${palettes.map(generateRamp).join('\n\n')}\n}`;
console.log(css);
// theme.ts — toggle logic with system preference fallback
type Theme = 'light' | 'dark' | 'system';
function getEffectiveTheme(preference: Theme): 'light' | 'dark' {
if (preference !== 'system') return preference;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
function applyTheme(preference: Theme) {
const effective = getEffectiveTheme(preference);
document.documentElement.setAttribute('data-theme', effective);
localStorage.setItem('theme-preference', preference);
}
// Initialize on page load — apply before first paint to prevent flash
const stored = localStorage.getItem('theme-preference') as Theme | null;
applyTheme(stored ?? 'system');
// Listen for system preference changes (only matters when preference='system')
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
const current = localStorage.getItem('theme-preference') as Theme;
if (current === 'system') {
applyTheme('system');
}
});
A product suite using this token approach can share the same primitives.css and tokens.css files across multiple applications — for example, a main search interface, a visualization tool, an admin dashboard, and a marketing site — ensuring that the accent color, the dark mode background, and the chart color palette are identical everywhere. When a design team adjusts the accent color’s chroma from 0.25 to 0.22 for better WCAG contrast compliance, the change propagates to all apps automatically.
The chart color tokens are particularly important for analytics views. Because all five chart colors share the same OKLCH lightness (0.65 in light mode, 0.72 in dark mode), they appear equally prominent in bar charts and line graphs — no single series dominates visually due to being a brighter hue. This perceptual equality is impossible to achieve with HSL-defined colors without manual per-hue adjustment.
The two-layer token architecture also enabled a high-contrast accessibility mode without touching any component styles. The team created a [data-theme="high-contrast"] selector that maps semantic tokens to more extreme primitive values — --color-text maps to pure white instead of neutral-100, --color-border maps to neutral-500 instead of neutral-800, and accent colors use higher chroma values. Components automatically pick up the high-contrast values through the same var() references they already use.
OKLCH can define colors outside the sRGB gamut (the color range that standard monitors can display). Browsers silently clamp out-of-gamut colors to the nearest displayable value, which can shift the hue or reduce chroma unpredictably. The palette generator reduces chroma at lightness extremes (very light and very dark shades) to ensure all tokens fall within sRGB, avoiding these silent clamps.
The theme initialization script runs synchronously in the <head> before the page renders. If it ran after page load (e.g., in a React useEffect), users would see a brief flash of the wrong theme. By reading localStorage and setting data-theme in a blocking script, the correct token values are available before the browser’s first paint.
For data visualization, all series colors should have equal perceptual weight so no series dominates the chart visually. OKLCH makes this trivial: define N colors at the same lightness and chroma, spaced evenly around the hue wheel. In HSL, you’d need manual lightness adjustments per hue to achieve the same effect (e.g., yellow needs lower HSL lightness than blue to look equally bright).
oklch(0.55 0.25 264) directly instead of var(--color-accent), they won’t respond to theme changes. All color references in component styles must go through semantic tokens — the only place raw OKLCH values should appear is in the primitive token definitions.culori library’s displayable() function, or use CSS color-mix() with a gamut-mapping keyword.:root but not in [data-theme="dark"], the dark theme silently falls back to the light value. This produces subtle bugs — e.g., dark text on a dark background — that are easy to miss in testing. Lint for token coverage across all theme selectors.localStorage. If the server renders with light theme classes and the client prefers dark, there’s a flash. Solve this with a blocking inline script in <head> that reads the preference and sets data-theme before the browser processes the rest of the document.