Every design system has this pattern: one brand color, eight shade variants, a slightly darkened hover state, and a lightened disabled state. For years, CSS had no way to produce those programmatically. You either committed all twelve values by hand or you pulled in Sass for its darken(), lighten(), and mix() functions.
color-mix() is the native CSS answer. It has been in every major browser since mid-2023, and it removes the preprocessor dependency entirely.
What color-mix() does
The function blends two colors at a specified ratio inside a chosen color space.
color-mix(in <color-space>, <color1> <percentage>, <color2>)
In practice:
.button {
background: color-mix(in srgb, #3b82f6 80%, black);
}
That gives you #3b82f6 mixed 80% with black — a darkened variant of your brand blue. Swap black for white and you get a lightened variant.
The percentage controls how much of color1 survives in the blend. 80% means 80% of the first color and 20% of the second. You can also specify both percentages explicitly: color-mix(in srgb, blue 30%, red 70%).
Why the color space argument matters
This is the part most tutorials rush past. The color space changes what the blend actually looks like.
/* Midpoint mix: srgb vs oklch */
.demo-srgb { background: color-mix(in srgb, #0000ff 50%, #ffff00); }
.demo-oklch { background: color-mix(in oklch, #0000ff 50%, #ffff00); }
In srgb, mixing blue and yellow at 50% produces a muddy gray — the channels average to near-neutral values. In oklch (a perceptually uniform color space), the midpoint is a vivid violet-green, because the mix travels through perceptually meaningful color space rather than raw channel math.
For tints and shades — mixing your brand color with white or black — the difference is more subtle but still real. oklch tends to preserve the perceived saturation of the original color better than srgb as you move toward white or black. srgb is predictable and is the right default when you want results that match what Sass historically produced.
Replacing Sass color functions
Sass's three most-used color manipulation functions map directly to color-mix():
/* Sass: darken($color, 15%) */
color-mix(in srgb, var(--brand) 85%, black)
/* Sass: lighten($color, 15%) */
color-mix(in srgb, var(--brand) 85%, white)
/* Sass: mix($color1, $color2, 60%) */
color-mix(in srgb, var(--color1) 60%, var(--color2))
The Sass percentage refers to how much change to apply; the color-mix() percentage refers to how much of the first color remains. That means darken($color, 15%) becomes color-mix(in srgb, $color 85%, black) — subtract the darkening amount from 100.
Building a token system without a preprocessor
The real power emerges when you combine color-mix() with CSS custom properties. You can define a single source color and derive the full scale from it, entirely in CSS.
:root {
--brand: #3b82f6;
--brand-50: color-mix(in oklch, var(--brand) 10%, white);
--brand-100: color-mix(in oklch, var(--brand) 20%, white);
--brand-200: color-mix(in oklch, var(--brand) 35%, white);
--brand-300: color-mix(in oklch, var(--brand) 55%, white);
--brand-400: color-mix(in oklch, var(--brand) 75%, white);
--brand-500: var(--brand);
--brand-600: color-mix(in oklch, var(--brand) 85%, black);
--brand-700: color-mix(in oklch, var(--brand) 70%, black);
--brand-800: color-mix(in oklch, var(--brand) 55%, black);
--brand-900: color-mix(in oklch, var(--brand) 35%, black);
}
Now a button component can use the scale without knowing any hex values:
.button {
background: var(--brand-500);
border-color: var(--brand-600);
color: white;
}
.button:hover {
background: var(--brand-600);
border-color: var(--brand-700);
}
.button:disabled {
background: var(--brand-200);
color: var(--brand-400);
}
Change --brand in one place and every derived shade updates automatically — across the entire page, in every component, with no build step and no recompilation.
This also composes cleanly with theme switching:
@media (prefers-color-scheme: dark) {
:root {
--brand: #60a5fa; /* lighter blue reads better on dark backgrounds */
}
}
The full shade scale re-derives itself from the new source color without any additional changes.
Browser support
color-mix() is Baseline 2023: Chrome 111 (March 2023), Firefox 113 (May 2023), Safari 16.2 (December 2022).
Unsupported browsers ignore the declaration, so the fallback is whatever value appeared before — usually the original custom property. For a graceful fallback, supply a pre-computed value first:
.button {
background: #3b82f6; /* fallback */
background: color-mix(in oklch, var(--brand) 100%, transparent);
}
There is no polyfill needed for the browsers that ship to production today.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The takeaway
Search your codebase for darken(, lighten(, or mix( from Sass. Each one is a preprocessor dependency that now has a native CSS equivalent. Replace them with color-mix() and move the color scale into custom properties so the whole system reacts to a single source value.
The native platform keeps absorbing what used to need a build step. Color manipulation was one of the last practical reasons to reach for a CSS preprocessor in new projects. That reason is gone.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)