You finally deleted sass-loader from your bundler config. Then you opened your stylesheet and realized half your color logic lived in functions and maps. darken(), lighten(), mix(), that $palette map you’ve copy-pasted across four projects — all of it needs somewhere to go in native CSS.
The CSS color-mix function is where most of it goes — the CSS nesting tutorial covers the structural migration if you need that piece too. Not all of it — and there’s a sharp edge with the oklch color space that none of the tutorials mention. Three patterns map directly to the Sass code you’re removing, plus one gotcha you’ll only see if you open DevTools and look for it. Let’s get to the code.
The 30-Second Mental Model
The CSS color-mix() function replaces Sass mix(), darken(), lighten(), and $palette color maps by blending two colors at runtime using custom properties. It generates tints, shades, and full palette scales without a build step — and it works with dynamic brand colors that change per user or theme.
color-mix() blends two colors in a chosen color space, with optional percentage weighting. Default is 50/50.
color-mix(in oklch, var(--brand) 70%, white)
That’s the whole shape: color-mix(in <color-space>, <color-a> <percent>, <color-b> <percent>). Skip the percentages and you get a clean halfway mix.
It’s Baseline 2023. Chrome 111+, Firefox 113+, Safari 16.2+. Global support is 89%+ as of 2026. Tailwind v4 already uses it under the hood for opacity modifiers like bg-blue-500/50. This is not bleeding edge — it’s shipped.
One decision framework before the patterns, because it’ll save you an hour of confusion later: color-mix() blends two colors. CSS relative color syntax — hsl(from var(--c) h s calc(l - 8%)) — modifies channels of one color. Use color-mix() when you have a base and a target (brand plus white). Use relative color syntax when you want to nudge L, C, or H of one color and nothing else. For conditional property values alongside color computation, the CSS if() function handles the conditional property side.
Most of your Sass replacements are the first kind. Starting with the one that retires an entire file.
Pattern 1: One Brand Color → a Full Palette
The CSS color-mix function replaces your Sass $palette map with runtime-computed shades. The Sass you’re killing looks like this:
$brand: #4ade80;
$palette: (
50: mix($brand, white, 10%),
100: mix($brand, white, 25%),
500: $brand,
700: mix($brand, black, 25%),
900: mix($brand, black, 50%),
);
Or worse — a hand-tuned scale baked into 12 variables at build time. Either way: rigid, rebuild-on-change, dies the moment you need runtime theming.
The CSS replacement defines the brand once and computes the rest:
:root {
--brand-500: #4ade80;
--brand-50: color-mix(in oklch, var(--brand-500) 10%, white);
--brand-100: color-mix(in oklch, var(--brand-500) 25%, white);
--brand-300: color-mix(in oklch, var(--brand-500) 60%, white);
--brand-700: color-mix(in oklch, var(--brand-500) 75%, black);
--brand-900: color-mix(in oklch, var(--brand-500) 50%, black);
}
.card {
background: var(--brand-50);
color: var(--brand-900);
border: 1px solid var(--brand-100);
}
Why oklch and not srgb? Perceptual uniformity. The 100, 300, 500 steps actually look like equal visual jumps. Mix the same colors in sRGB and you get a muddy gray in the middle — a 50/50 blend of blue and yellow turns into the visual equivalent of pond water.
The killer feature isn’t the syntax. It’s that --brand-500 is now a runtime variable. Change it in JavaScript, change it from a data-theme attribute, swap it per tenant in a white-label app — the entire scale updates with zero rebuild. The $palette map was a build-time decision. This is a runtime one. CSS dynamic theming with color-mix() is what makes multi-tenant color systems actually work without a rebuild step.
That handles backgrounds, borders, and text on a static scale. The moment a user hovers a button, you’re back to the next Sass function on the chopping block.
Pattern 2: Hover, Focus, Active (Replaces darken() and lighten())
The Sass:
.button {
background: $primary;
&:hover { background: darken($primary, 8%); }
&:active { background: darken($primary, 12%); }
}
The CSS:
.button {
--bg: var(--brand-500);
background: var(--bg);
}
.button:hover { background: color-mix(in oklch, var(--bg), black 8%); }
.button:active { background: color-mix(in oklch, var(--bg), black 12%); }
The win isn’t just shorter code. It’s that --bg is a parameter. Drop the same hover/active rules on any element with a --bg custom property — buttons, links, badges, tags — and they all get correct interaction states. One rule, the whole UI.
Honesty beat: Sass darken() adjusts the L channel of HSL directly. color-mix(..., black 8%) is close but not identical. If you need pixel-exact replication of an existing Sass design, use hsl(from var(--bg) h s calc(l - 8%)) instead — that’s the relative color syntax doing what darken() actually did under the hood. For new code, the color-mix() version is more legible and the visual difference is invisible to humans.
Using oklch matters more here than in Pattern 1. With sRGB, an 8% shift on a red button darkens it noticeably more than the same shift on a green button. Equal percentages produce unequal visual jumps. In oklch, your red and green buttons feel like they belong to the same design system. That’s what perceptual uniformity buys you in practice.
Generated states look great — until a user uploads a brand color that’s almost white. Then your white-on-light-tinted text disappears, and the next pattern saves you.
Pattern 3: Accessible Text Against Dynamic Backgrounds
When --brand is user-configurable — multi-tenant SaaS, white-label, theming UI — hardcoded color: white breaks on pale yellow, hardcoded color: black breaks on navy. You can’t pick a winner at build time because you don’t know the color yet.
The color-mix() bridge buys you most of the way there:
.brand-text {
color: color-mix(in oklch, var(--brand), black 80%);
}
.brand-text-on-dark {
color: color-mix(in oklch, var(--brand), white 80%);
}
That gives you a near-black text that picks up a hint of brand — readable on most light brand colors, with personality. Invert for dark brands.
The better answer for 2026: contrast-color(). It just shipped in Firefox 146 and Safari 26 — Chrome is still behind a flag. The full version:
.brand-text {
color: contrast-color(var(--brand));
}
That returns black or white based on background luminance. No math, no thresholds, no manual fallbacks. The whole problem becomes a one-liner.
Until it’s Baseline everywhere, ship color-mix() and watch the support tables. Treat color-mix() as the bridge — contrast-color() is the eventual answer, and the code you write now should be easy to swap. For WCAG-aware programmatic decisions where you need to enforce a specific contrast ratio (think compliance audits), reach for an APCA library like apca-w3. color-mix() is not a contrast calculator and pretending it is will fail an audit.
Three patterns, browser support sorted. But there’s one default everyone recommends that has a sharp edge nobody mentions.
The One Gotcha: When oklch Quietly Lies to You
Every tutorial recommends oklch. Most never tell you this: highly saturated source colors mixed in oklch can land outside the sRGB gamut your monitor can show. The browser clips silently. You see a different color than the math says you’ll see.
Concrete example:
color-mix(in oklch, oklch(0.7 0.4 30), white 50%)
A vivid red and white, mixed in oklch. The arithmetic produces a chroma the display can’t render. The browser gamut-maps it inward, you get a flatter pink than you designed, and nothing in your code or DevTools warning bar tells you it happened.
How to catch it: open DevTools, hover the swatch in the Styles panel, check the computed color value against what you specified. If the chroma is being capped, your gradient or hover state will look paler than the source colors implied.
When to fall back to srgb: if your source colors are already vivid (saturated brand reds, electric blues, neon greens) and you only need a small tint or shade shift, color-mix(in srgb, ...) is more predictable. The blend won’t be perceptually uniform, but it’ll match what you specified.
Rule of thumb: oklch for generated scales from a single source (Pattern 1). srgb for hover and active states when the source is already saturated (sometimes Pattern 2). Test in DevTools before shipping a critical brand surface. Three minutes now saves a Slack thread later.
This is the kind of edge case that separates a CSS color-mix tutorial that works from one that ships subtle color bugs. Native CSS color manipulation is powerful — but it still needs testing.
Ship It
The Sass migration isn’t blocked on color functions anymore. Pattern 1 retires your $palette map. Pattern 2 retires darken() and lighten(). Pattern 3 holds the line on accessible contrast until contrast-color() reaches Baseline.
If you’re comparing the CSS color-mix function vs Sass color functions, the tradeoff is clear: you lose build-time guarantees and gain runtime flexibility. For any app that themes, white-labels, or lets users pick colors, that’s the right trade.
The one-line decision recap: color-mix() to blend two colors, relative color syntax to nudge channels of one color, contrast-color() (soon) for accessible text. Reach for srgb when your source is saturated and oklch is silently clipping.
Delete sass-loader. Keep one file of CSS custom properties. Ship color logic in CSS, not in a build step.
When contrast-color() reaches Baseline, Pattern 3 becomes a one-liner and you’ll come back here to delete half of it. Until then, the web performance checklist has one fewer build dependency to audit — and that’s the whole point.