Hover and Active States With color-mix(): One Token, Every Shade
A button needs at least four colours: its resting background, a slightly darker hover, a darker still pressed state, and a muted disabled state. Multiply by primary, secondary and danger variants, then by light and dark themes, and a design system is maintaining dozens of hand-picked hex values that must stay in step with each other. When the brand colour changes, all of them must change. The narrow problem this page solves is deriving every interaction shade from a single token with color-mix(), choosing a colour space that makes the shades look consistent across hues, and transitioning between them smoothly. It belongs to Color & Theme Transitions in the CSS-Only Micro-Interactions & Animations guide.
Why derive states instead of listing them
Interaction states are relationships, not colours. "Hover is the base colour, a little darker" is a rule; #3f4fd8 is one application of it. Storing the rule means every variant and every theme gets a correct hover for free, and a rebrand is a one-line change. Storing the result means every new variant is a design task and every rebrand is a hunt through the stylesheet.
color-mix(in <space>, <color> <percentage>?, <color> <percentage>?) blends two colours in a chosen colour space. Mixing the base colour with a little black darkens it; with a little white, lightens it; with transparent, fades it. Because the base can be a custom property, the rule works for any colour it is given.
The colour space decides how even the shades look
The first argument to color-mix() is the interpolation space, and it is not a detail. Mixing 15% black into a bright yellow and into a deep blue in srgb produces visibly different amounts of darkening — the yellow turns muddy olive while the blue barely changes. oklch and oklab are designed so that equal numeric changes produce roughly equal perceived changes, which is exactly what a consistent hover state needs.
The diagram is schematic, but the effect is easy to reproduce: compare color-mix(in srgb, gold, black 20%) with color-mix(in oklch, gold, black 20%) side by side, and the sRGB result reads as a different, greener colour while the OKLCH one reads as a darker gold.
The complete implementation
The buttons below each set one custom property — their base colour — and derive hover, active, disabled and focus-ring colours from it. Changing --brand on any button recolours all of its states.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>color-mix() states</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
.btn {
/* The only colour a variant sets. */
--brand: #4f46e5;
/* Derived states, in a perceptual space so every hue steps evenly. */
--brand-hover: color-mix(in oklch, var(--brand), black 12%);
--brand-active: color-mix(in oklch, var(--brand), black 24%);
--brand-disabled: color-mix(in oklch, var(--brand), white 55%);
--brand-ring: color-mix(in oklch, var(--brand) 55%, transparent);
padding: 0.55rem 1.1rem;
border: 0;
border-radius: 8px;
background-color: var(--brand);
color: #ffffff;
font: inherit;
cursor: pointer;
transition: background-color 140ms ease, box-shadow 140ms ease;
}
.btn:hover { background-color: var(--brand-hover); }
.btn:active { background-color: var(--brand-active); }
.btn:focus-visible {
outline: 2px solid var(--brand-active);
outline-offset: 2px;
box-shadow: 0 0 0 6px var(--brand-ring);
}
.btn:disabled {
background-color: var(--brand-disabled);
color: color-mix(in oklch, var(--brand), black 45%);
cursor: not-allowed;
}
/* Variants change one token. */
.btn--teal { --brand: #0f766e; }
.btn--danger { --brand: #dc2626; }
.btn--amber { --brand: #b45309; }
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
}
</style>
</head>
<body>
<button class="btn" type="button">Primary</button>
<button class="btn btn--teal" type="button">Teal</button>
<button class="btn btn--danger" type="button">Delete</button>
<button class="btn btn--amber" type="button">Warning</button>
<button class="btn" type="button" disabled>Disabled</button>
</body>
</html>
The derived tokens are declared on .btn itself rather than on :root. That matters: a custom property's value is computed where it is declared, so --brand-hover defined on :root would mix the root's --brand, and a variant that overrides --brand on a button would not change its hover. Declaring derived tokens on the component makes them re-evaluate against each instance's base.
The key technique: mixing toward black, white or transparent
The three mixing partners do different jobs, and choosing between them is most of the design work.
- Toward black darkens: natural for hover and pressed states on filled buttons, where a darker surface reads as "pushed in". On dark themes, a filled button on a dark page often looks better lightening on hover instead — mixing toward white — so the state becomes more prominent rather than disappearing into the background.
- Toward white desaturates and lightens: natural for disabled states, which should read as washed out. White-mixing alone can drop contrast below useful levels, so disabled text is darkened separately.
- Toward transparent produces a translucent version of the colour: ideal for focus halos, selection backgrounds and subtle tinted surfaces that must work on any background.
Mixing toward transparent deserves a note on percentages: color-mix(in oklch, var(--brand) 55%, transparent) means 55% brand and 45% transparent, which is equivalent to the brand colour at 55% alpha. It is the cleanest way to get a transparent variant of a colour stored as a hex value, which has no alpha channel to adjust directly.
Contrast is not automatic
A derived shade is only as accessible as its arithmetic. A 12% darker hover on white text generally increases contrast, but a light brand colour such as a pale yellow may start below the 4.5:1 text contrast requirement of WCAG 1.4.3 and stay below it. The disabled state is exempt from contrast requirements — WCAG excludes inactive components — but it should still be legible enough that users understand what is unavailable.
Forced-colours mode is the other case to test. When a user enables a high-contrast theme in their operating system, browsers replace author colours with a small system palette, and every derived shade collapses to the same system button colour. That is correct behaviour, but it means hover and pressed states can no longer be distinguished by colour at all. Keep a non-colour cue for interactive states that matter — a border, an underline, or the outline on focus — so the component remains usable when colour is taken away.
The practical routine is to check each derived state for each brand colour in a contrast tool once, when the tokens are designed, and to pick the text colour per variant where necessary. The mixing rules then keep the relationship consistent as long as the base colours stay in the same lightness range. Smooth Theme Switching Transitions covers how the same derived tokens behave across light and dark themes.
Variation: animating the mix amount itself
Transitioning background-color between two mixes already produces a smooth change. Sometimes the amount should animate independently — a card whose tint deepens the longer the pointer rests on it, or a progress state that darkens as it fills. Registering the percentage as a typed custom property makes it animatable.
@property --tint {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.card {
--brand: #4f46e5;
background-color: color-mix(in oklch, var(--brand) var(--tint), white);
transition: --tint 400ms ease;
}
.card:hover,
.card:focus-within {
--tint: 12%;
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
}
Registration is what makes the difference, just as it is for the gradient technique in Animating Custom Properties With @property. An unregistered --tint would flip from 0% to 12% at the midpoint instead of blending.
Browser support
color-mix() is supported in Chrome and Edge 111+, Firefox 113+ and Safari 16.2+, and the oklch and oklab interpolation spaces are supported alongside it. @property, used to animate the mix amount, is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+. For older engines, declare a plain hex fallback before each color-mix() declaration — background-color: #4338ca; background-color: var(--brand-hover); — and the unsupported value is ignored.
FAQ
Why use color-mix() instead of hard-coded hover colours?
Hard-coded shades must be picked for every brand colour and every theme, and they drift when the base colour changes. color-mix() derives each state from the base token at runtime, so changing one variable updates every hover, active and disabled shade consistently.
Which colour space should color-mix() use for UI states?oklch or oklab for most UI work. They are perceptually uniform, so mixing 15% black darkens different hues by a similar visible amount. srgb mixing tends to muddy saturated colours and darkens some hues much more than others.
Does mixing with black guarantee accessible contrast? No. It darkens the colour, which usually increases contrast against white text, but the amount depends on the starting colour. Check every derived state against its text colour, and for light text on light brands you may need to mix toward black by more, or switch the text colour.
Can a colour produced by color-mix() be transitioned?
Yes. The result is an ordinary colour value, so transitions on background-color or color interpolate from the old mix to the new one. If the percentage itself lives in a custom property you want to animate, register that property with @property as a percentage.
Related
- Color & Theme Transitions — the parent guide.
- Smooth Hover Effects Without JavaScript — the motion side of hover states.
- Creating Accessible Focus Indicators — where the derived ring colour is used.
- Style Queries With Custom Properties — switching variants by token.
Related articles
More pages in the same section.