CSS · · 4 min read

color-mix() Is Quietly Replacing My Sass Variables

The last thing Sass was doing for me was color manipulation. Turns out the platform has caught up — and it does a better job.

I removed the last Sass dependency from a project last month. It felt anticlimactic. I'd been hanging onto Sass for a single reason: I needed darken(), lighten(), and mix(). Custom properties are great, but they've never let you compute a color from another color — until color-mix().

What the old way looked like

// SCSS
$brand: #6366f1;
$brand-dark: darken($brand, 12%);
$brand-muted: mix($brand, white, 20%); // 20% brand, 80% white

.button:hover { background: $brand-dark; }
.tag          { background: $brand-muted; }

This works, but the colors are baked in at compile time. Change $brand at runtime (say, for a theme toggle or user-selected accent) and none of the derived colors follow along. You'd have to expose every variant as its own CSS custom property.

The color-mix() approach

:root {
  --brand: #6366f1;
}

.button:hover {
  background: color-mix(in oklch, var(--brand) 85%, black);
}

.tag {
  background: color-mix(in oklch, var(--brand) 20%, white);
}

Now change --brand anywhere — on :root, on a component, inside a media query — and every derived color recalculates automatically. This is the composability that Sass color functions always wanted to have but couldn't, because they operated at build time.

Why oklch matters

The color space you pass to color-mix() is not cosmetic. The sRGB color space (the default for most browser color math historically) doesn't distribute lightness evenly across hues. Mixing two sRGB colors often produces a muddy middle — the infamous "mixing green and red gives brown" problem.

oklch is a perceptually uniform color space. The "L" in oklch is true perceptual lightness: two colors with the same L value look equally bright to the human eye, regardless of hue. This means:

  • Darkening a color shifts only lightness, preserving hue and chroma
  • Mixing two oklch colors gives you a visually predictable midpoint
  • Tints and shades feel like they genuinely belong to the same palette
/* sRGB: hue can shift, results feel off */
color-mix(in srgb, oklch(0.6 0.2 270) 50%, black)

/* oklch: lightness shifts cleanly, hue is preserved */
color-mix(in oklch, oklch(0.6 0.2 270) 50%, black)

In practice, the difference is most visible when you're generating a full tonal scale from a single brand color. sRGB scales tend to desaturate strangely at the extremes. oklch stays consistent.

Building a theme from a single variable

Here's what my setup looks like now. One custom property, several derived values, no Sass:

:root {
  --brand: oklch(0.6 0.22 270);

  --brand-dim:    color-mix(in oklch, var(--brand) 60%,  black);
  --brand-muted:  color-mix(in oklch, var(--brand) 15%,  transparent);
  --brand-tint:   color-mix(in oklch, var(--brand) 20%,  white);
  --brand-border: color-mix(in oklch, var(--brand) 35%,  transparent);
}

If I change --brand (say, in a data-theme="green" variant), every derived value recomputes. The entire component theme follows a single number.


I won't pretend Sass has nothing left to offer. Mixins, partials, and loops are still genuinely useful. But for the one thing I was still reaching for it daily — color math — the platform has quietly become better. color-mix() in oklch produces nicer palettes than darken() ever did, and it's live at runtime. I'm not going back.