color-mix(): Hover States Without a Second Color Definition
Every design system carries a shadow palette: brand-600 for the button, brand-700 for hover, brand-100 for the tinted background, brand-200 for the border. Four hand-picked hexes per color, multiplied by every color, maintained forever. color-mix() computes them instead:
.btn {
--accent: #4f46e5; /* ONE source of truth */
background: var(--accent);
}
.btn:hover { background: color-mix(in oklab, var(--accent), black 12%); }
.btn:active { background: color-mix(in oklab, var(--accent), black 20%); }
.badge {
background: color-mix(in oklab, var(--accent), white 85%); /* the -100 tint */
color: color-mix(in oklab, var(--accent), black 30%); /* readable dark variant */
border: 1px solid color-mix(in oklab, var(--accent), white 60%);
}
Change --accent and the entire derived family follows. This is what makes user-configurable theming practical in pure CSS - one stored color, everything else computed. (My own site's accent-color picker works exactly this way.)
Why "in oklab" and not srgb
The color space argument matters more than it looks. Mixing in srgb reproduces the muddy midpoints you know from gradients (blue + white drifts gray-ish); oklab is perceptually uniform, so "20% toward black" looks like 20% darker for every hue, and mixes stay vivid. Rule of thumb: oklab for tint/shade generation, srgb only when matching a legacy design tool's math.
The transparency idiom
/* Old: rgba(79, 70, 229, 0.15) - hand-converted, breaks when the hex changes */
.focus-ring {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent), transparent 75%);
}
Mixing with transparent produces alpha variants of a variable - the thing everyone wanted from var() and rgba() for years (you can't insert a hex variable into rgba's channels). Focus rings, overlays, tinted hovers on arbitrary theme colors: solved.
Practical notes
- Percentages:
color-mix(in oklab, A 30%, B)means 30% A / 70% B; a single trailing percentage on the second color reads most naturally for "push A toward B by N". - It nests - mixing a mix is fine, though at that point consider whether intermediate custom properties would document intent better.
- Contrast still needs checking: computed 12%-darker hovers are consistent, but a pale user-chosen accent mixed toward white can fail on white text. Test the extremes of any user-configurable input.
- Support: Chrome 111+, Firefox 113+, Safari 16.2+ - green across the board since 2023. Relative color syntax (oklch(from var(--accent) calc(l - 0.1) c h)) is the even sharper scalpel arriving behind it, but color-mix covers most needs today.
Palettes were always derived data. Now the browser does the deriving, and your theme file shrinks to the colors that are actual decisions.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.