DEV Community

Cover image for You reach for Sass to mix colors. `color-mix()` does it natively.
Parsa Jiravand
Parsa Jiravand

Posted on

You reach for Sass to mix colors. `color-mix()` does it natively.

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>)
Enter fullscreen mode Exit fullscreen mode

In practice:

.button {
  background: color-mix(in srgb, #3b82f6 80%, black);
}
Enter fullscreen mode Exit fullscreen mode

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); }
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 */
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)