Prism

Dynamic color from first principles

Rebuilding Material Design 3's dynamic color with OKLCH — one seed hue in, forty color roles out.

designcolorcss

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.

js
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:

js
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:

PaletteHueChroma
Primaryseed0.13
Secondaryseed0.045
Tertiaryseed + 60°0.09
Neutralseed0.012
Error29° fixed0.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:

glsl
uniform vec3 uColorA;   // primary tone 60
uniform vec3 uColorB;   // tertiary tone 60
uniform vec3 uColorC;   // secondary tone 80

Pick 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 localStorage and 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.