CSS color-mix() and relative colors: tints, hovers and transparency
color-mix() blends two colors in a color space you choose, and relative color syntax lets you edit one channel of an existing color, such as oklch(from var(--brand) calc(l - 0.1) c h). Both run in the browser, work with CSS variables and replace most Sass color functions. Mix in oklab for tints and hovers, in srgb for transparency.
6 min read
How color-mix() works
color-mix() takes a color space and two colors with optional percentages and returns the color between them. color-mix(in oklab, var(--brand) 90%, black) means 90% brand and 10% black, mixed in Oklab. With one percentage, the other color gets the rest. With none, each gets 50%.
The browser computes the result at runtime, so it works with custom properties, currentColor and theme switches. Sass functions run once at build time and cannot see var() values.
If the two percentages add up to less than 100%, the result also turns partly transparent: color-mix(in srgb, red 30%, blue 30%) is an even mix at 60% opacity. Keep the sum at 100% unless you want that.
CSS
.button {
--brand: #2563eb;
background: var(--brand);
}
.button:hover {
background: color-mix(in oklab, var(--brand) 90%, black);
}
.badge {
background: color-mix(in oklab, var(--brand) 10%, white);
border-color: color-mix(in srgb, var(--brand) 30%, transparent);
}Which color space to mix in: srgb, oklab or oklch
The space changes the result more than most people expect. Below is pure blue #0000ff mixed 50/50 with pure yellow #ffff00, computed with the same math the browser uses.
In srgb the channels average to exactly 128, 128, 128: the mix is #808080, a gray with zero chroma. Oklab also mixes along a straight line, and blue and yellow sit on opposite sides of it, so they mostly cancel into a dull grayish blue. Oklch interpolates the hue angle instead, taking the short way from 264° to 110°, so the midpoint is a vivid teal at hue 187° that keeps the chroma of both inputs.
- Blue #0000ff#0000ff
- Yellow #ffff00#ffff00
- in srgb: #808080#808080
- in oklab: #6cabc7#6cabc7
- in oklch: #00bdb0#00bdb0
| Space | Blue + yellow | OKLCH L, C | Blue-600 + yellow-400 | OKLCH L, C |
|---|---|---|---|---|
| srgb | #808080 | 0.600, 0 | #909880 | 0.665, 0.035 |
| oklab | #6cabc7 | 0.710, 0.077 | #90a3ad | 0.703, 0.026 |
| oklch | #00bdb0 | 0.710, 0.262 | #00bd9e | 0.703, 0.194 |
The second pair is Tailwind’s #2563eb and #facc15. The oklch results are outside sRGB, so chroma is shown before gamut mapping and the hex is the nearest color an sRGB screen can show.
Two things stand out. Srgb lands at lightness 0.600, while the perceptual midpoint between blue (0.452) and yellow (0.968) is 0.710: that is the dark, muddy middle of srgb gradients. And only oklch keeps the mix vivid, at the cost of passing through a hue neither input has.
Rule of thumb: mix in oklab for tints, shades and hover states, where you stay on one hue and want even lightness steps. Mix in oklch when both colors are saturated and you want a saturated result. Mix in srgb for transparency, where it matches how the browser composites layers.
Recipes: hover, tints and transparent variants
All values below use #2563eb as --brand. White text on it has a contrast ratio of 5.17:1.
Mixing 10% black darkens more in oklab than in srgb: white text rises to 6.50:1 against 6.11:1. Oklab and oklch give the same result when the second color is black or white, because those have no hue to interpolate.
| Recipe | CSS | Result | Contrast |
|---|---|---|---|
| Hover, 10% darker | color-mix(in oklab, var(--brand) 90%, black) | #1f55cc | 6.50:1 with white text |
| Active, 20% darker | color-mix(in oklab, var(--brand) 80%, black) | #1947ae | 8.22:1 with white text |
| Hover mixed in srgb | color-mix(in srgb, var(--brand) 90%, black) | #2159d3 | 6.11:1 with white text |
| Tint background, 10% | color-mix(in oklab, var(--brand) 10%, white) | #e8f0ff | 4.51:1 with brand text |
| Tint background, 20% | color-mix(in oklab, var(--brand) 20%, white) | #d2e2ff | 3.95:1 with brand text |
| Transparent, 20% | color-mix(in srgb, var(--brand) 20%, transparent) | rgba(37, 99, 235, 0.2) | Looks like #d3e0fb on white |
Watch the tints. Brand text reads at 5.17:1 on white, 4.51:1 on a 10% tint and only 3.95:1 on a 20% tint, which fails WCAG AA for normal text. If you put brand-colored text on a brand tint, use the hover shade for the text.
Mixing with transparent keeps the color and lowers only alpha, because the browser mixes premultiplied values. The result adapts to what is behind it: the same rule looks like #d3e0fb on white and #132651 on a dark #0f172a surface. A tint mixed with white stays pale on a dark page, so for components that appear in both themes the transparent version is usually the better choice.
Relative color syntax: edit one channel
Relative color syntax takes an existing color, splits it into channels and lets you rewrite any of them with calc(). In oklch(from var(--brand) l c h), the keywords l, c and h hold the lightness (0 to 1), chroma and hue of --brand.
Unlike color-mix(), it changes exactly one property: lower lightness without touching chroma, rotate hue without changing lightness, or add alpha to a color stored as hex.
| Recipe | CSS | Result from #2563eb | White text |
|---|---|---|---|
| Darker | oklch(from var(--brand) calc(l - 0.1) c h) | #0442c8 | 8.02:1 |
| Lighter | oklch(from var(--brand) calc(l + 0.1) c h) | #4484ff | 3.51:1 |
| Half the chroma | oklch(from var(--brand) l calc(c * 0.5) h) | #4e6faf | 4.98:1 |
| Hue +30° | oklch(from var(--brand) l c calc(h + 30)) | #7a49df | 5.46:1 |
| Complementary | oklch(from var(--brand) l c calc(h + 180)) | #956700 | 4.98:1 |
| Gray, same lightness | oklch(from var(--brand) l 0 h) | #707070 | 4.95:1 |
| 50% alpha | oklch(from var(--brand) l c h / 0.5) | rgba(37, 99, 235, 0.5) | Depends on the background |
CSS
:root {
--brand: #2563eb;
--brand-hover: oklch(from var(--brand) calc(l - 0.08) c h);
--brand-muted: oklch(from var(--brand) l calc(c * 0.5) h);
--brand-ring: oklch(from var(--brand) l c h / 0.4);
--accent: oklch(from var(--brand) l c calc(h + 30));
}Look at the four rows that keep l: changing chroma or hue moves white-text contrast only between 4.95:1 and 5.46:1, against 5.17:1 for the original. Lightness drives contrast, so hue variants built this way keep roughly the same accessibility. The lighter and complementary colors are outside sRGB at full chroma, and the browser maps them to the nearest color it can show.
color-mix() vs relative colors vs Sass darken()
Use color-mix() when you think in terms of “a bit of black” or “a bit of transparency”. Use relative color syntax when you think in channels: “the same color, 0.1 darker”. Both replace most Sass color functions and follow a theme change without a rebuild.
| color-mix() | Relative color syntax | Sass darken() | |
|---|---|---|---|
| Runs | In the browser | In the browser | At build time |
| Works with var() and currentColor | Yes | Yes | No |
| Color model | The space you name: srgb, oklab, oklch, hsl and more | Any color function: oklch(), rgb(), hsl(), lab() | HSL only |
| Best for | Tints, shades, transparency, blending two tokens | Changing one channel: lightness, chroma, hue or alpha | Old code with fixed colors |
| Step size | A share of the distance to the second color | Exact, in the units of the channel | Fixed HSL points, uneven to the eye |
| Supported since | Chrome 111, Safari 16.2, Firefox 113 | Chrome 119, Safari 18, Firefox 128 | Any browser, output is static |
Why Sass darken() gives uneven steps
darken($color, 10%) subtracts 10 points of HSL lightness. HSL lightness is not perceived lightness, so the visible change depends on the hue. Here is darken(10%) next to oklch(from … calc(l - 0.1) c h) on four common UI colors, measured with ΔE OK, where about 0.02 is the smallest difference you can see.
| Color | Sass darken(10%) | ΔE OK | OKLCH l − 0.1 | ΔE OK |
|---|---|---|---|---|
| #2563eb | #134cca | 0.078 | #0442c8 | 0.099 |
| #facc15 | #d7ad05 | 0.099 | #d9ab00 | 0.101 |
| #16a34a | #107636 | 0.136 | #00842b | 0.094 |
| #dc2626 | #b21d1d | 0.090 | #b70000 | 0.089 |
The same darken(10%) changes green 1.7 times more than blue. The OKLCH version stays between 0.089 and 0.101 for every hue; the small spread comes from gamut mapping, since some darker colors fall just outside sRGB. Sass itself recommends color.adjust() or color.scale() over darken(), and in plain CSS you do not need either.
Browser support and fallbacks
color-mix() works in Chrome and Edge 111, Safari 16.2 and Firefox 113, all released in 2023. Relative color syntax arrived in Chrome and Edge 119 and Firefox 128; Safari shipped an early version in 16.4 and the current syntax in Safari 18. Every current browser supports both.
The usual trick of a plain value on the line before does not work when the new value contains var(). A declaration with var() is always accepted at parse time, so the browser discards your fallback line and, if it cannot compute color-mix(), falls back to the inherited or initial value. Wrap the modern version in @supports instead.
CSS
.button:hover {
background: #1f55cc;
}
@supports (color: color-mix(in oklab, red, blue)) {
.button:hover {
background: color-mix(in oklab, var(--brand) 90%, black);
}
}
@supports (color: oklch(from red l c h)) {
.badge {
border-color: oklch(from var(--brand) l c h / 0.3);
}
}Questions and answers
What does color-mix() do in CSS?
color-mix() blends two colors in a color space you name and returns the result, for example color-mix(in oklab, var(--brand) 90%, black) for a color 10% darker than the brand. It runs in the browser, so it works with CSS variables and currentColor.
Which color space should I use in color-mix()?
Use oklab for tints, shades and hover states, because it gives even lightness steps and keeps the hue. Use oklch when mixing two saturated colors and you want a saturated result, and srgb when mixing with transparent. Mixing blue and yellow in srgb gives flat gray #808080, while oklch gives a vivid teal.
How do I darken a color on hover in pure CSS?
Use background: color-mix(in oklab, var(--brand) 90%, black) or background: oklch(from var(--brand) calc(l - 0.1) c h). For #2563eb the first gives #1f55cc and the second #0442c8, raising white-text contrast from 5.17:1 to 6.50:1 and 8.02:1.
How do I make a CSS variable color transparent?
Write color-mix(in srgb, var(--brand) 20%, transparent) or oklch(from var(--brand) l c h / 0.2). Both work when --brand is stored as hex and keep the color itself unchanged, lowering only its opacity.
Is color-mix() supported in all browsers?
Yes, every current browser supports it: Chrome and Edge since 111, Safari since 16.2 and Firefox since 113. For older browsers, put a static color first and the color-mix() version inside @supports, because a line with var() cannot fall back on its own.
What is the difference between color-mix() and relative color syntax?
color-mix() blends two colors by a percentage, which suits tints, shades and transparency. Relative color syntax, like oklch(from var(--brand) l c calc(h + 30)), rewrites individual channels of one color, which suits exact lightness steps, hue shifts and alpha.