Dynamic color from first principles
Rebuilding Material Design 3's dynamic color with OKLCH — one seed hue in, forty color roles out.
Material Design 3's best idea isn't a component. It's the claim that color is a function, not a list — hand the system one seed color and every surface, button and outline derives from it, with contrast guaranteed. Google implements this with HCT, a color space they built by welding CAM16 hue/chroma onto L lightness. HCT is lovely and also enormous*. I wanted the effect, not the dependency.
Turns out the modern web ships a perceptual color space in CSS itself: OKLCH. Same three dials — lightness, chroma, hue — and oklch() is supported everywhere that matters. So Prism's color engine is OKLCH tonal palettes, ~80 lines, no tables.
Tonal palettes
MD3 thinks in tonal palettes: thirteen tones of one hue, from black to white. Roles then pick tones — in light mode primary is tone 40 and its container is tone 90; dark mode flips them to 80/30. Contrast comes free from the tone gap, because OKLCH lightness tracks perceived lightness closely.
const TONE_L = { 0: 0, 10: 0.21, 20: 0.32, 30: 0.42, 40: 0.52,
50: 0.62, 60: 0.71, 70: 0.80, 80: 0.88, 90: 0.945, 95: 0.972, 99: 0.993, 100: 1 };
const tone = (hue, chroma, t) =>
`oklch(${TONE_L[t]} ${chroma * chromaScale(t)} ${hue})`;The one subtlety is chromaScale: chroma must taper near the extremes or tones 90–99 fall outside sRGB and clip toward neon. A cosine falloff works:
const chromaScale = (t) =>
t <= 0 || t >= 100 ? 0 : Math.sin((t / 100) * Math.PI) ** 0.6;One hue in, five palettes out
From a single seed hue I derive the full MD3 cast:
| Palette | Hue | Chroma |
|---|---|---|
| Primary | seed | 0.13 |
| Secondary | seed | 0.045 |
| Tertiary | seed + 60° | 0.09 |
| Neutral | seed | 0.012 |
| Error | 29° fixed | 0.14 |
The tertiary rotation is the trick that makes generated schemes feel designed — a violet seed quietly brings peach along, teal brings lime. Nature does complementary pairs; so should your CSS.
Wiring it to everything
The engine writes forty CSS custom properties on :root — --md-primary, --md-on-surface, --md-surface-container-high and friends — and every component reads only tokens. Switching themes is swapping numbers; the DOM never re-renders.
The fun part: the WebGL scenes read the same tokens. On every theme change I parse three role colors to linear RGB and hand them to the aurora shader as uniforms:
uniform vec3 uColorA; // primary tone 60
uniform vec3 uColorB; // tertiary tone 60
uniform vec3 uColorC; // secondary tone 80Pick crimson from the palette button and the northern lights above the fold turn crimson with it, in the same frame. That coherence — UI chrome and painted light agreeing about color — is what "dynamic color" was always supposed to mean.
What I'd tell past me
- OKLCH hue ≠ HCT hue. Blues drift a few degrees. Nobody will ever notice.
- Test tone 90 containers against tone 10 text with a contrast checker; my first falloff curve failed AA on exactly one hue (yellow, obviously).
- Persist the seed in
localStorageand inline a tiny script in<head>to apply it before first paint, or you get a flash of default purple.
Total cost: 80 lines of JavaScript and one very smug author.