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.
Text, border, and the button below all come from one token.
—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.
--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.