Relative color syntax, and the end of generated variants
You can now derive a hover state from a token in CSS itself. No build step, no generated values, no drift between the two.
↑ Click any of these to repaint the entire site with it.
Every design system generates variants. The brand color needs a hover state, a pressed state, a disabled version, a subtle background tint. Those get computed at build time, written out as tokens, and shipped as a list of hex codes.
The problem with generated values is that they drift. Somebody adjusts the brand color in the config, the variants regenerate, and now a component that was hand-tuned against the old value is subtly wrong. Or worse: somebody adjusts a variant directly and it no longer bears any relationship to its source.
Relative color syntax removes the generation step entirely.
The syntax#
--brand: oklch(62% 0.19 258);
--brand-hover: oklch(from var(--brand) calc(l + 0.06) c h);
--brand-pressed: oklch(from var(--brand) calc(l - 0.05) c h);
--brand-muted: oklch(from var(--brand) l calc(c * 0.35) h);
--brand-tint: oklch(from var(--brand) 96% calc(c * 0.25) h);
--brand-fade: oklch(from var(--brand) l c h / 0.12);from var(--brand) destructures the source color into l, c and h, which you then use — modified or not — in the new value. It works in every color function, so you can read a color in one space and write it in another.
Every current browser supports it.
Why it matters more in OKLCH#
You could do something similar with HSL, and the result would be unreliable. "Six percent lighter" in HSL produces a different amount of visible change on every hue, so a hover rule that looks right on your blue looks flat on your yellow.
In OKLCH the lightness channel is perceptual, so calc(l + 0.06) produces the same perceived change everywhere. The rule becomes something you can write down once and apply to the whole palette:
Hover is six points lighter. Pressed is five points darker. Muted is 35% of the chroma.
Three sentences that a designer can state and an engineer can implement literally.
The gamut caveat#
calc(c * 1.4) can ask for chroma the display cannot produce. The browser clamps, which is safe, but it means the result may not be what you expected — and worse, two different source colors can clamp to visibly different amounts.
If you are pushing chroma up, check the result. If you are pulling it down, which is the common case, you are always safe.
What it does not replace#
Ramps. An eleven-step scale needs a lightness curve and a chroma curve, and expressing those as ten separate calc() expressions is worse than generating them. Generate ramps; derive states.
Contrast guarantees. calc(l + 0.06) does not know about your background. If you need a color that clears 4.5:1, you have to solve for it, and CSS cannot solve. Generate the ones that carry an accessibility requirement.
Documentation. A generated token list is something a designer can look at. A calc() expression is something they have to run in their head. There is a real argument for generating tokens purely so the values are visible in the design tool.
The pattern that works#
Generate the primitives. Derive the states.
:root {
/* Generated: the ramp, with its lightness and chroma curves */
--blue-50: oklch(97.1% 0.013 258);
--blue-500: oklch(61.2% 0.190 258);
--blue-900: oklch(33.5% 0.114 258);
/* Semantic: which primitive does which job */
--color-action: var(--blue-600);
/* Derived: states, as rules rather than values */
--color-action-hover: oklch(from var(--color-action) calc(l + 0.06) c h);
--color-action-pressed: oklch(from var(--color-action) calc(l - 0.04) c h);
--color-action-ring: oklch(from var(--color-action) l c h / 0.4);
}Change --color-action to point at a green and every state follows automatically, at the same perceptual offsets, with no build step and nothing to regenerate.