← blog
CSSNovember 1, 2026 · 7 min read

Stop Hardcoding Color Variants. Relative Color Syntax Does It.

A design system ships one brand color and four hand-picked hex variants for hover, pressed, border, and tint. Change the brand color and four of those five silently go stale. CSS relative color syntax derives every variant from the one color you actually update.

Parsa Jiravand · Frontend engineer · building bestpractic
Stop Hardcoding Color Variants. Relative Color Syntax Does It.

The PR comment was two words: "wrong blue." Not a bug report, not a reproduction — a designer scrolling through a staging build, stopping on the "Upgrade" button's hover state, and flagging it because something about it felt off against the rest of the rebrand that had shipped two weeks earlier.

They were right, and it took longer than it should have to find out why. The button's resting color matched the new brand blue exactly — that token had been updated the day the rebrand landed. Its hover color was still the old brand blue, darkened. Nobody had touched it, because nobody remembered it existed.

Open almost any design system's color file and you'll find the same shape: one brand color, and a handful of hand-picked variants sitting next to it that all look derived but are actually just... more hex codes.

CSS
1
2
3
4
5
6
7
:root { --brand: #3b6fd6; --brand-hover: #2f58ab; /* "a bit darker" — picked by eye, once */ --brand-active: #244289; /* "darker still" — same deal */ --brand-border: #6e92e1; /* "a bit lighter" */ --brand-tint: rgba(59, 111, 214, 0.08); /* "faint background wash" */ }

Every one of those four extra values is somebody's good-faith attempt, at some point in the past, to eyeball a lighter or darker or more-transparent version of --brand. They're not wrong on day one. The problem shows up the day --brand changes and the other four don't — because CSS has no idea they were ever related. To the engine, --brand-hover isn't "darker --brand." It's just a string that starts with #2f58ab, full stop.

The "obvious" fix is discipline: a comment reminding whoever edits --brand to also update the other four, or a design-tokens build step that regenerates them from a single source before CSS ever sees them. Both work, right up until the person making a one-line brand tweak six months from now doesn't know the build step exists, or skims past the comment. Discipline that depends on nobody being in a hurry isn't a fix — it's a bet, and the rebrand PR is the bet losing.

Here's the reframe that matters: --brand-hover was never really an independent color. It was always a function of --brand — "darker, same hue." --brand-tint was always "the same color, 8% opaque." The relationship was real and well understood by whoever first picked those hex codes. What got lost was that the relationship lived in a person's head for thirty seconds and then evaporated, leaving only its one-time output behind, frozen in a stylesheet forever.

What you actually want is for CSS to store the relationship, not just a snapshot of what it once produced — and that's a genuinely new category of thing for CSS to express. For most of CSS's life, a color value was a dead end: #3b6fd6 tells the renderer what to paint, but nothing about where it came from or how to get a cousin of it. color-mix() loosened that slightly — you can blend two colors — but blending two colors isn't the shape of this problem. You don't have two brand colors to mix; you have one color, and you want its lightness turned down a notch while everything else about it stays put.

Before you keep reading: what would you even call the CSS feature that lets you say "same color, just darker"? It's a fair question, because until relatively recently there wasn't a clean answer.

Relative color syntax lets a color function take another color as input and expose that color's own channels as variables you can do math on, right there in the function call:

CSS
1
2
3
4
5
6
7
8
9
10
11
12
13
:root { --brand: #3b6fd6; /* "same hue and chroma as --brand, just darker" */ --brand-hover: oklch(from var(--brand) calc(l - 0.12) c h); --brand-active: oklch(from var(--brand) calc(l - 0.20) c h); /* "same color, lighter, for a border" */ --brand-border: oklch(from var(--brand) calc(l + 0.15) c h); /* "same color, 8% opaque" */ --brand-tint: oklch(from var(--brand) l c h / 8%); }

Read the oklch(from var(--brand) ...) part literally: "take --brand, convert it to OKLCH, and give me three variables back — l for lightness, c for chroma, h for hue." Whatever you write after that is just CSS math on those three numbers (plus an optional alpha after the slash). calc(l - 0.12) means "this color's own lightness, minus 0.12" — not a hardcoded lightness value, a relationship to whatever --brand happens to be right now.

Change --brand to a rebrand's new blue, or swap it entirely for a dark-mode theme, and every line below it recomputes. There's no second hex code to forget, because there is no second hex code — --brand-hover isn't stored anywhere; it's derived, every time the browser needs it, from the one variable a human actually edits.

This isn't OKLCH-only, either. rgb(), hsl(), and color() all support the same from syntax, exposing whichever channels that color space naturally has:

CSS
1
2
3
4
5
6
/* rgb()'s own channels: r, g, b (0–255), alpha */ .badge { background: rgb(from var(--brand) r g b / 15%); } /* hsl()'s own channels: h, s, l, alpha — s and l come back as plain numbers (0-100), not percentages, so the math below uses 10, not 10% */ .link:hover { color: hsl(from var(--brand) h s calc(l - 10)); }

OKLCH is usually the better pick for lightness math specifically, because it's designed so that "lightness" tracks perceived brightness consistently across hues — nudging l down by the same amount darkens a yellow and a blue by roughly the same perceived amount. Nudge hsl()'s l channel the same way and the result can feel inconsistent hue to hue, because HSL's lightness was never built to model human perception in the first place.

Runs right in your browser — poke at it and watch the concept react live.

calc(l - 0.12) is honest math, which means it'll happily produce a number outside the range that makes sense. Pick a brand color that's already quite dark, subtract 0.20 from its lightness, and l can go negative — which isn't a color error so much as a "this doesn't mean anything" result, rendered as whatever the browser decides black looks like.

The fix is the same clamp() you'd reach for anywhere else in CSS, wrapped around the channel math:

CSS
--brand-hover: oklch(from var(--brand) clamp(0, calc(l - 0.12), 1) c h);

That one change makes the derivation safe for any input color, including ones your design system doesn't control yet — a user-customizable accent color, say, or a brand color read from a CMS. Skip the clamp and the formula only works for colors in roughly the lightness range you tested it against, which defeats a good chunk of the point: a derivation that only works for some inputs is just a more elaborate hardcoded value.

Safe to ship — but the rollout order isn't what you'd guess. Safari 16.4 (March 2023) was the earliest browser with something, but it was a partial pass at an older draft of the spec with real parsing bugs; that implementation was later replaced, not extended. Chrome and Firefox did the work of actually matching the final spec through 2024 (Firefox in Firefox 128, July 2024), and Safari didn't catch up — bug fixes included — until Safari 18 in September 2024. So for the exact forms used above, Safari was last to ship the behavior this post describes, not first. Either way, that was over two years ago: by now every major evergreen browser has unprefixed, unflagged, spec-correct support, and there's no realistic "but what about X" objection left to raise for a typical modern browser matrix. If your project still has to support browsers from before that window, the fallback is simple and honest: keep writing literal hex variants for those browsers, and layer the relative syntax on top for everyone else — it degrades to "the hardcoded value you already had," not to a broken page.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The bug wasn't really "someone forgot to update a hex code." It was that the stylesheet never had anywhere to put the fact that --brand-hover meant "darker --brand" — so that knowledge lived in a person's memory instead, with exactly the shelf life you'd expect. Relative color syntax isn't a shorter way to write the same five hex codes. It's a way to stop writing four of them at all.

Where does your own color system still keep that kind of relationship in a comment instead of in the CSS itself?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.