Color scales in OKLCH
Author in OKLCH. One hue per ramp, L anchored 0.97→0.22 and spaced evenly, chroma as a curve clamped per step, siblings at the same fraction of their own ceiling, dark mode by remapping steps, contrast repaired by moving L only.
Raw markdown On GitHubreferences/surface/color-scales-oklch.md
Taught in
An oklch() value is a measurement, not a recipe: L is how bright the color actually looks, C how vivid, H where it sits. Because the numbers track perception, palette math, contrast repair, and dark mode become arithmetic. Everything new is authored in OKLCH; hex survives only where a third-party consumer parses hex. This node builds the ramps that color-monochromatic asks for and feeds the checks in contrast-and-color-scheme.
Reading a value
oklch(0.586 0.222 17.6) — L 0–1, C from 0 (gray) to ~0.4 at the extreme, H 0–360 (≈20 red, 90 yellow, 145 green, 195 cyan, 260 blue, 330 pink). Alpha goes after a slash, never as a fourth comma argument. Three decimals is plenty.
Building a ramp
- Anchor L and space evenly. Step 50 ≈ L 0.97, step 950 ≈ L 0.22, linear between. Even L spacing is the point: every adjacent pair looks equally far apart. Never run to L 0 or 1; chroma collapses there and the ends stop reading as the hue.
- Shape chroma as a curve. Express each step's C as a fraction of that step's sRGB ceiling: ~30% at the extremes rising to ~85% around 400–500. Tints stay airy, shades stay rich, nothing leaves the gamut.
- Clamp per step. The ceiling
maxC(L, H)moves with lightness, so a saturated base necessarily loses absolute chroma toward both ends. That is the gamut's geometry, not a defect. - Hue is fixed for all eleven steps. Converting an inherited scale and finding more than ~10° of hue spread end to end means it drifts visibly; rebuild it on the mid-tone hue.
--berry-50: oklch(0.97 0.007 330);
--berry-400: oklch(0.67 0.251 330); /* chroma peaks where this hue's gamut is widest */
--berry-950: oklch(0.22 0.03 330);Families of hues
Brand + success + warning feel like one system when each -500 sits at the same L and each hue takes the same fraction of its own ceiling, never the same absolute C. Cyan is cramped at every lightness; violet is roomy. One copied C value over-saturates the roomy hues and flattens the tight ones.
Dark mode is a remap
Keep one scale and swap which steps the roles point at: --surface 50 ↔ 950, --ink 950 ↔ 50, 200 ↔ 800, 300 ↔ 700. Perceptually linear L keeps the pairs as distinct after the flip as before. Never hand-pick a second palette. Desaturate brand accents 20–30% if they vibrate on dark, and keep the top canvas the lightest dark surface — see dark-mode.
Contrast lives in the L gap
Chroma and hue barely move contrast. To repair a failing pair, push the foreground's L away from the background's L and leave C and H alone; raising saturation "to make it pop" does nothing. Fast triage: a surface with L > 0.6 wants dark text; near-white grounds (L ≥ 0.85) want foreground L ≤ 0.45; near-black grounds (L ≤ 0.25) want L ≥ 0.75. Then run the real check: APCA |Lc| ≥ 75 for body (90 preferred), ≥ 60 other content text, ≥ 45 large, ≥ 30 non-text and disabled; WCAG 2 4.5:1 body, 3:1 large, when compliance language names it. The thresholds live in contrast-and-color-scheme.
Gamut and P3
Syntactically valid values can exceed what sRGB shows. Clamp C holding L and H, and layer wide-gamut chroma behind @media (color-gamut: p3) on top of the sRGB-safe base; ceilings, fallbacks and migration steps are in oklch-gamut-and-migration. Tailwind v4 is authored in OKLCH; custom @theme scales should be too, and /50 opacity suffixes compose out of the box.
When to apply
Any new palette, any hex → OKLCH migration (swap values only; keep keywords, gradient shape, and hex-expecting config), any "is this readable" question, any dark theme.
Gotcha
HSL's lightness is decorative — hsl(55 100% 50%) is nearly white-bright and hsl(255 100% 50%) is dark. A ramp spaced on HSL's L inherits that lie and its tints slide toward violet. Never generate scales in HSL.
Sources
- Emil Kowalski's design-engineering practice on color, distilled by HKTITAN.
- APCA (Myndex) and WCAG 2 contrast thresholds; CSS Color Level 4
oklch(). - Related: color-monochromatic, contrast-and-color-scheme, dark-mode.
surfacecoloroklchpalettecontrastdark-mode