Lab 5 min read

Relative Color Syntax Playground

Derive a whole component's states from one color token by pulling its channels out and doing math on them. Drag one hue, watch hover, active, border, and ring re-theme live.

A button needs at least four colors that aren’t the button color: a hover, an active, a border, a focus ring. The usual setup is four hand-picked values in a token file, tuned once to look related. Change the brand hue and all four drift out of sync until you re-pick them by hand.

Relative color syntax removes the re-picking. You give it an origin color with from, and it hands that color’s channels back as keywords (l, c, h in oklch; r, g, b in rgb) that you reuse or run through calc().

/* Decompose the origin, then rebuild with one channel nudged */
--brand: oklch(0.62 0.17 25);
--brand-hover: oklch(from var(--brand) calc(l + 0.07) c h);

The origin gets converted into whatever space you’re constructing in, so oklch(from #FF3366 l c h) pulls the oklch channels straight out of a hex value. That alone is worth the feature.

One token, a whole component

Here the button’s default, hover, active, border, focus ring, subtle panel, and text are all oklch(from var(--brand) …). Drag the hue and every derived state re-themes together, because there’s only one real color. Hover and press the button to see the derived states.

Every state derived from one --brand
Subtle panel

Text, border, and the button below all come from one token.

25
—brand: oklch(0.62 0.17 25)
.component {
  --brand: oklch(0.62 0.17 25);

  /* Each state pulls --brand apart and nudges one channel */
  --brand-hover:  oklch(from var(--brand) calc(l + 0.07) c h);
  --brand-active: oklch(from var(--brand) calc(l - 0.09) c h);
  --brand-border: oklch(from var(--brand) calc(l - 0.12) c h);
  --brand-ring:   oklch(from var(--brand) l c h / 0.35);  /* same color, 35% alpha */
  --brand-panel:  oklch(from var(--brand) 0.96 calc(c * 0.35) h); /* light tint */
  --brand-ink:    oklch(from var(--brand) 0.4 c h);       /* dark text, same hue */
}

/* Change one number, the whole set re-derives */

The lightness offsets are the same for any hue because oklch lightness is perceptual, so calc(l + 0.07) reads as one step brighter whether the brand is red or teal. That is the part color-mix() toward white can’t promise: mixing toward white desaturates unevenly per hue, while nudging l directly keeps chroma where you put it.

Channels are just values

from doesn’t only feed lightness. Rotate the hue for a complement, scale chroma for a muted variant, drop alpha for an overlay. Same origin, all math on its channels.

Relatives derived from one base by channel math
base
h + 180
h + 30
c × 0.35
l = 0.85
/ 0.4
--base: oklch(0.6 0.19 265);

/* Complement: rotate hue halfway round the wheel */
background: oklch(from var(--base) l c calc(h + 180));

/* Analogous: small hue step */
background: oklch(from var(--base) l c calc(h + 30));

/* Muted: keep hue and lightness, cut chroma */
background: oklch(from var(--base) l calc(c * 0.35) h);

/* Overlay: same color, 40% alpha */
background: oklch(from var(--base) l c h / 0.4);

Two things to keep in mind. Channel values clamp to their range, so calc(l + 0.2) on an already-light color stops at 1 rather than wrapping. And relative color resolves at used-value time, not as an animatable number, so if you want to transition a derived channel you still register it with @property and interpolate that, then feed it into the color.

Browser support landed across the board in 2024: Chrome 119, Safari 16.4, Firefox 128. For older engines, declare a plain fallback first and let the relative version override in the cascade.