color-mix() Replaces SCSS lighten() and darken(): A Migration

SCSS lighten($color, 10%) and darken($color, 10%) are still in millions of stylesheets. They are also broken in two important ways. CSS color-mix() fixes both - and works at runtime with custom properties, which SCSS cannot.

What is broken about lighten() / darken()

Problem 1: they operate in HSL. HSL lightness is not perceptual - lighten(blue, 20%) and lighten(yellow, 20%) produce wildly different perceived steps.

Problem 2: they bake at compile time. If you switch to dark mode by toggling a CSS variable, every lighten() call that referenced the old token still has the old value baked in. You end up shipping two stylesheets.

The color-mix() replacement

SCSS | CSS
lighten($brand, 10%) | color-mix(in oklch, var(--brand), white 18%)
darken($brand, 10%) | color-mix(in oklch, var(--brand), black 18%)
mix($a, $b, 30%) | color-mix(in oklch, var(--b), var(--a) 30%)
transparentize($c, 0.4) | color-mix(in srgb, var(--c) 60%, transparent)
desaturate($c, 20%) | (see below)

The percentage shifts: SCSS adds 10% lightness directly; color-mix() mixes in 18% of white, which produces a similar perceived step in OKLCH. Adjust to taste.

Desaturation needs a different trick

There is no direct "desaturate by N" in color-mix(). Mix toward a neutral of the same lightness:

--brand-muted: color-mix(in oklch, var(--brand), oklch(from var(--brand) l 0 h) 50%);

The oklch(from ... ) relative-color syntax extracts the brand's L and H, sets chroma to 0, and you mix toward that grayscale equivalent. Result: a desaturated version that keeps the brand's lightness.

Migration recipe

List every lighten/darken call. A regex grep gets most of them.

Group by base color. Most projects mix from 3-5 base tokens.

Promote bases to CSS variables if they are not already.

Replace each call with the OKLCH mix equivalent. Tune the percentage by eye.

Delete the SCSS color helpers from your build. You no longer need node-sass or sass just for color.

What you gain

Runtime theming. Change --brand, every mix updates instantly. Light/dark mode is one line.

Smaller CSS. No precomputed variants for hover/active/disabled.

Better blends. OKLCH math beats HSL math, especially on cool hues.

Fewer tools. Plain CSS does the work; less build pipeline.

What you lose

Honestly, not much. The only edge case: tooling that reads computed styles for design-token export. color-mix() resolves to color-mix(...), not the final HEX, until the browser paints. Most exporters now resolve it; old ones may need a fallback.

Visualise any color-mix() recipe before you ship it - paste two colors and a ratio into our Color Mixer.