An ongoing series on techniques that ship with the browser already - no library, no build step required.
A brand color is never really one color. There’s the color itself, a darker version for hover states, a much lighter version for a subtle background tint, maybe a muted version for a disabled state. The usual way to get there is a designer picking each one by eye in Figma, a developer copying each result into CSS as its own hex value, and everyone hoping they stay in sync the one time the brand color ever changes.
color-mix() skips the hand-picking. Give it one variable and a percentage, and it computes the rest:
var(--accent) mixed toward white; right of center is the same variable mixed toward black.
HTML
<figure class="mix-demo">
<div class="mix-row">
<span class="mix-swatch mix-1"></span>
<span class="mix-swatch mix-2"></span>
<span class="mix-swatch mix-3"></span>
<span class="mix-swatch mix-4"></span>
<span class="mix-swatch mix-5"></span>
<span class="mix-swatch mix-6"></span>
<span class="mix-swatch mix-7"></span>
<span class="mix-swatch mix-8"></span>
<span class="mix-swatch mix-9"></span>
</div>
<figcaption>
Nine swatches, one CSS variable. Left of center is <code>var(--accent)</code> mixed toward white; right of center is the same variable mixed toward black.
</figcaption>
</figure>CSS
.mix-row {
display: flex;
border: 1px solid var(--color-gray-200);
}
.mix-swatch {
flex: 1;
aspect-ratio: 1;
background: var(--color-gray-300); /* shows if color-mix() isn't supported - see below */
}
.mix-1 {
background: color-mix(in oklch, white 80%, var(--accent) 20%);
}
.mix-2 {
background: color-mix(in oklch, white 60%, var(--accent) 40%);
}
.mix-3 {
background: color-mix(in oklch, white 40%, var(--accent) 60%);
}
.mix-4 {
background: color-mix(in oklch, white 20%, var(--accent) 80%);
}
.mix-5 {
background: var(--accent);
}
.mix-6 {
background: color-mix(in oklch, var(--accent) 80%, black 20%);
}
.mix-7 {
background: color-mix(in oklch, var(--accent) 60%, black 40%);
}
.mix-8 {
background: color-mix(in oklch, var(--accent) 40%, black 60%);
}
.mix-9 {
background: color-mix(in oklch, var(--accent) 20%, black 80%);
}
figcaption {
margin-top: 0.6rem;
font-size: 0.8rem;
color: var(--color-gray-500);
}Not one of those nine values is written down anywhere as its own hex code. Change --accent and every swatch above follows it. (On a browser old enough to not know color-mix(), the nine squares fall back to a single flat gray instead of vanishing outright - a plain background on the shared class, sitting underneath the nine color-mix() ones, so an invalid formula just leaves the fallback in place instead of leaving nothing.)
The whole idea
.button:hover {
background: color-mix(in oklch, var(--accent) 85%, black);
}
.badge {
background: color-mix(in oklch, var(--accent) 15%, white);
color: var(--accent);
}
color-mix(in oklch, A P1, B P2) reads as “mix A and B, in the oklch color space, at these percentages.” Give only one percentage and the other fills the remainder, so color-mix(in oklch, var(--accent) 85%, black) is shorthand for “85% accent, 15% black” - a hover state that’s just a darkened version of the same color, computed, not chosen. The badge line does the opposite: a soft 15%-strength tint for a background, that stays visibly related to the brand color because it’s mathematically derived from it.
Why in oklch, specifically
The color space you mix in isn’t a technicality. Mix two colors in the browser’s older default space, sRGB, and the steps in between can drift through a dull, slightly muddy middle - most noticeable mixing hues that sit far apart on the wheel, less so with a simple tint-toward-white or shade-toward-black, but still there. oklch was built so lightness changes evenly and predictably as you move through it, which is exactly the property you want from a ramp like the one above: each step should read as a consistent nudge, not an uneven jump partway through. It costs nothing extra to ask for - just the three letters after in.
Naming what you derive
The real payoff is in the naming, not the swatches. Once a hover state is color-mix(in oklch, var(--accent) 85%, black) instead of its own hex value, it has a name that describes what it is - “the accent, slightly darkened” - rather than a name that just describes what color it happens to be today. (This site’s own --accent-hover is still a hand-picked hex value rather than a derived one, incidentally - which is a perfectly reasonable choice too. color-mix() is an option worth having, not an obligation to use everywhere.)
Where it’s supported
color-mix() cleared every major engine within a few months of each other back in 2023, so this isn’t a bleeding-edge bet:
Browser support · color-mix()
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
What it won’t do for you
color-mix() computes a color. It has no idea whether that color is legible. A 15%-strength tint that looks like a tasteful background can still fail as a text color, and a shade that reads as “darker” to the eye isn’t guaranteed to clear a contrast ratio just because the formula moved it toward black. Treat the output the way you’d treat any other color: derived or not, run it past an actual contrast check before you ask it to hold text.
It’s the same underlying habit as dark mode built from one set of variables - fewer values hand-picked and hard-coded, more of them computed from the one that actually matters. If your own brand color is currently scattered across a dozen slightly-different hex values that all drifted apart over time, that’s usually a sign the palette is overdue for a proper pass.



