Build a Whole Palette in Pure CSS with color-mix()
You used to need a build step to do color math. Sass had lighten() and darken(); everyone else reached for JavaScript. That era is over. Modern CSS mixes and derives colors right in the stylesheet, and you can grow a whole scheme from one variable without a single dependency.
The old way, and why it's dead
Picture the setup most teams limped along with for a decade. You defined a brand color, then hand-picked a lighter version for hover, a darker one for the pressed state, a pale one for surfaces. Six shades, all typed by eyeball. Change the brand and you re-typed all six.
Sass automated some of that with lighten() and darken(), but those functions worked in HSL and drifted the hue as they went. You also paid for a compiler. The other option was computing shades in JavaScript at runtime, which meant color flashing in after paint and a script tax on every visitor. Both approaches solved a math problem that the browser can now solve itself.
color-mix(): blend two colors, no tooling
The workhorse is color-mix(). Hand it two colors and a ratio; it returns the blend. A tint is your color pushed toward white:
/* tint — 85% brand, 15% white */ color-mix(in oklch, var(--brand) 85%, white) /* the matching shade — brand toward black */ color-mix(in oklch, var(--brand) 85%, black)
Drop the percentage lower and the mix pulls harder toward the second color. That's the whole idea. One function replaces a Sass helper, a preprocessor, and the runtime script all at once.
Look closely at those two words: in oklch. They matter more than they look.
Why "in oklch" beats the default
Leave out the color space and color-mix() blends in sRGB. sRGB is where color math goes wrong. Mix a saturated blue with a saturated yellow through sRGB and the midpoint sags into a muddy, darkened gray before climbing out the other side. Same dead-zone that wrecks careless gradients — the reason we wrote a whole piece on avoiding the muddy middle in CSS gradients.
Mixing in oklch routes the blend through a perceptual space instead. Lightness stays even. The intermediate stays clean and vivid, no surprise gray in the middle. If you have never met that color space, our explainer on what OKLCH actually is covers the L, C, and H in plain terms. For quick reference: it keeps perceived brightness honest, which is exactly what a tint-and-shade scale needs. Prefer in hsl if you want, but oklch gives you the cleanest ramp.
Want to see the difference before committing? Blend the same two colors across each space in the Mixer and watch the midpoint change. sRGB dips. OKLCH holds.
Relative color syntax: reshape one color into another
The second modern trick is relative color syntax. It reads a color apart, lets you rework the pieces, and reassembles them. The magic word is from. It destructures the origin color into channel keywords you can reuse or run through calc().
Lighten a brand color by bumping its lightness channel:
/* same chroma, same hue, lightness nudged up */ oklch(from var(--brand) calc(l + 0.12) c h) /* the exact same color at half opacity */ rgb(from var(--brand) r g b / 50%)
Notice what those channel letters do. Inside the parentheses, l, c, h, r, g, and b become live numbers pulled from the origin. Keep the ones you want. Recompute the rest. That second example is my favorite for state layers — an alpha version of the brand that tracks the brand automatically, so a translucent overlay never falls out of sync.
Where color-mix() pushes a color toward another color, relative syntax edits one channel in place. Different tools, same goal.
Put it together: a scheme from one line
Here is the payoff. Declare a single --brand, then derive everything else from it. Surface, border, the brand itself, hover, text — five roles, one source of truth.
--surface: color-mix(in oklch, var(--brand) 8%, white); /* barely-there wash, ~92% white */ --border: color-mix(in oklch, var(--brand) 25%, white); /* a hair more brand for edges */ --brand: oklch(0.62 0.19 264); /* your one real decision */ --brand-hover: color-mix(in oklch, var(--brand) 88%, black); /* 12% black, a touch deeper for hover */ --text: oklch(from var(--brand) 0.22 c h); /* a dark, on-brand ink for body copy */
Change that one oklch() line and the whole set moves with it. Surface, border, hover, ink — all recomputed by the browser, all still in tune. No rebuild. No flash. Reload and it's done. Need the starting oklch() value for a hex you already like? Paste it into the Converter and copy the result straight in.
This pairs well with a real understanding of tints, shades, and tones — mixing toward white gives you tints, toward black gives shades, and the two color-mix() lines above are literally that distinction in code. For a full accent scheme rather than a light-to-dark ramp, spin the hue with the Harmonies tool and feed each result in as its own --brand.
Browser support and the fallback
Both features are ready. color-mix() and relative color syntax reached Baseline support across current browsers through 2025 and into 2026, meaning the browser your visitors actually run understands them. You can ship this today.
Older engines are the exception, and the fix is one extra line. Declare a plain value first, then the computed one below it:
background: #eef1fb;thenbackground: var(--surface);
A browser that doesn't grok color-mix() ignores the second line and keeps the static hex. Newer ones take the computed value. That's the whole safety net — cheap insurance while the last holdouts age out.
Common questions
color-mix() or relative color syntax — which should I use?
Reach for color-mix() when you're blending toward another color: tints toward white, shades toward black, a hover that leans into the accent. Reach for relative syntax when you want to edit one channel of a single color — lift its lightness, drop its chroma, or spin off an alpha version. Most schemes use both.
Is this production-ready in 2026?
Yes. Both landed in every major browser, and a one-line hex fallback covers anything ancient. Nothing to install, nothing to compile.
Why mix "in oklch" instead of the default?
Because the default blends in sRGB, where the midpoint can slump into a dull gray. OKLCH holds perceived brightness steady across the mix, so your tint-to-shade steps look even instead of dipping in the middle.
Start with one --brand and let the browser build the rest. Grab your seed color's oklch() value from the Converter, and you're one line away from a scheme that maintains itself.
Ready to try it? Every tool on Paleta runs free in your browser — no sign-up, nothing uploaded.
Explore the tools →