Understanding the CSS color-mix() Function for Dynamic Color Blending

Use CSS color-mix() to blend colors dynamically in any color space, with a complete fallback pattern for browsers that do not support the function.

Layering a semi-transparent white over a brand color to fake a tint looks wrong on every background that is not pure white. The CSS color-mix() function replaces that hack with a real computation: it interpolates two colors in a chosen color space and returns a concrete color value you can use anywhere a color is accepted. Here is the exact swap. The card background below is #2c3e50. The old approach used rgba(255,255,255,0.15). The new one mixes in white directly.

.card {
  /* old hack: opacity on white, wrong on dark backgrounds */
  background: rgba(255, 255, 255, 0.15);
  /* new: mix the actual brand color with white in oklch */
  background: color-mix(in oklch, #2c3e50 85%, white);
}

Why The Mixed Result Works

The result of that mixing is a computed value of oklch(0.381 0.066 0.009), a slightly lighter, slightly desaturated version of the original blue. Because the mixing happens in oklch, a perceptually uniform color space, the lightening feels even across the tonal range. Against the default page background of #fafafa, that mixed color has a WCAG 2.2 contrast ratio of 8.1:1. It passes AAA for normal text. The old rgba version, computed over the same background, gives an effective color of #b6bcc4 and a contrast ratio of only 2.4:1. Fail. That is the difference between a working tint and a washed-out one.

Baseline Status and What It Means for Your Work

As of the publication date, 2026-09-16, the CSS color-mix() function is Widely available on the Baseline 2024 grouping, per the Web Platform Status dashboard. That status means the feature works in the current and previous major versions of all core browsers. The low date on MDN Baseline is 2023-05-09 (Firefox 113), and the high date is 2024-05-09 (Safari 16.2). The 30-month rolling window from that high date puts the feature firmly in the widely available category now. For your daily CSS work, you can ship color-mix() without a polyfill for any browser that has received an update since mid-2023. The only stragglers are device-locked browsers: an old iOS Safari on an unsupported iPhone, or an Android WebView inside an app that has not updated in three years. Those users are a real fraction, and the fallback pattern below covers them. Check caniuse for your audience’s exact numbers.

The @supports Query and Two-Value Fallback Pattern

Feature detection in CSS itself is the @supports rule. It handles color-mix() cleanly because the syntax is a property-value pair. The pattern is two declarations: the static color first for older browsers, then the mixed color inside an @supports guard. The static color must be a real, computed value. A hex, an rgb, an hsl. You chose it deliberately as the closest approximation of the mixed result. Do not use a variable there. Custom properties resolve at the computed-value stage and an unsupported function inside var() can invalidate the whole declaration.

.button {
  /* fallback: a flat color that reads as the same tint on white */
  background: #4a5d6e;
}

@supports (color: color-mix(in srgb, red, blue)) {
  .button {
    /* modern: mix the actual brand color with black for depth */
    background: color-mix(in oklch, #2c3e50 90%, black);
  }
}

The @supports condition tests the exact syntax color-mix(in srgb, red, blue), a minimal valid pair that does not depend on your specific colors. If the browser understands that, it understands the full function. The fallback #4a5d6e is not a guess. It is the result of mixing #2c3e50 with 10% black in sRGB, computed offline once and hardcoded. That gives you a two-value pattern that ships to everyone and upgrades where possible.

Syntax: Spaces, Percentages, and Omissions

The formal syntax is color-mix( <color-interpolation-method> , [ <color> && <percentage [0,100]>? ]#{2} ). The comma separates the interpolation method from the two color arguments. Each color argument is a color value optionally followed by a percentage. The most common mistake is using a comma between the two colors instead of a space. The second most common is putting the percentage before the color, writing 50% red instead of red 50%. The third is assuming both percentages are required. They are not. Omit one and it is set to 100% minus the other. Omit both and each is 50%. So color-mix(in srgb, red, blue) is exactly red 50% and blue 50%. And color-mix(in srgb, red 70%, blue) is red 70% and blue 30%.

/* all of these produce the same mid-purple in sRGB */
color-mix(in srgb, red, blue);
color-mix(in srgb, red 50%, blue 50%);
color-mix(in srgb, red 50%, blue);
color-mix(in srgb, red, blue 50%);

How Normalization Handles Uneven Sums

When both percentages are present and their sum is less than 100%, the colors are normalized proportionally: red 20%, blue 40% becomes red 33.33%, blue 66.67%. When the sum exceeds 100%, the same normalization applies in reverse. This is defined in the CSS Color 5 specification at the W3C, and it is stable across engines. The result type is a computed <color> value. Feed it into border, box-shadow, gradient, filter, or any other property that accepts a color.

Choosing the Right Color Space: oklch vs srgb vs hsl

The color-interpolation method is the first argument after color-mix(. It determines where the mixing happens. The default is srgb, which is fine for quick work but produces muddy results because it is not perceptually uniform. The better default for systematic color work is oklch or oklab. Both are perceptually uniform color spaces where the same numerical change looks like the same visual change. Mixing in oklch keeps lightness and chroma separate. A mix of a dark blue with white stays on the same hue path instead of drifting toward gray. The hsl() space, by contrast, is cylindrical sRGB and perceptually uneven. A 50% mix of red and yellow in hsl gives an orange that looks darker than either parent. The same mix in oklch stays visually balanced.

/* srgb: muddier, but what you get by default */
background: color-mix(in srgb, #ff6b6b 50%, #4ecdc4);

/* oklch: cleaner, perceptually balanced */
background: color-mix(in oklch, #ff6b6b 50%, #4ecdc4);

For design systems, the rule is simple: use oklch unless you have a specific reason to use srgb (matching a legacy brand color defined only in that gamut, for example). The wider gamut of oklch also lets you mix colors that are outside sRGB, like Display P3 values. The result stays within the gamut of the output device through a process called gamut mapping. The hsl() space still exists for legacy code, but it is the wrong tool for dynamic mixing. Its lightness axis is not linear with perception. The light-dark() function, a separate CSS feature that pairs with color-mix(), lets you switch between light and dark theme values. It does not perform mixing. It picks one of two colors based on the user’s preference.

Using color-mix() with Custom Properties for Theme Tokens

Custom properties and color-mix() are a natural pair. A custom property holds a color and the mixing happens at computed-value time. Define a single brand hue and derive all your tints, shades, and interactive states from it. No repeating the base value across the stylesheet. The cascade and inheritance rules for custom properties apply normally. A custom property set on a parent is inherited by children unless overridden. A color-mix() that references it resolves when the property is used, not when it is defined.

:root {
  --brand: #2c3e50;
  --brand-hover: color-mix(in oklch, var(--brand) 85%, black);
  --brand-disabled: color-mix(in oklch, var(--brand) 60%, white);
  --brand-soft: color-mix(in oklch, var(--brand) 15%, white);
}

.btn-primary {
  background: var(--brand);
}
.btn-primary:hover {
  background: var(--brand-hover);
}
.btn-primary:disabled {
  background: var(--brand-disabled);
}

The Custom Property Trap

There is one trap: a custom property that contains color-mix() is only valid if the browser supports the function. Put the mixed value directly into a custom property and an unsupported browser makes the entire custom property invalid at computed-value time. Any var() referencing it falls back to its fallback value or fails. That is why the fallback pattern must live on the property that consumes the custom property, not on the custom property itself. A resilient pattern: define the base color as a custom property, then use @supports around the derived custom properties. Fall back to a static value you compute once.

Building a Fallback That Does Not Break: Interop and Real-World Gaps

The real-world interop gap for color-mix() is not about syntax. All engines that support it implement the same parse rules. The gap is in the older browsers that never got the feature. The @supports guard handles those, but you have to be careful about where you put it. A declaration that uses color-mix() outside of @supports will be ignored by an unsupported browser. The earlier declaration above it still applies. That is the standard progressive enhancement pattern. The failure case occurs when you put the mixed color in a custom property and then use that property in multiple places. Each use needs its own fallback. If any of those uses is in a property without a fallback declaration above it, the browser drops the entire style rule.

/* wrong: this custom property is invalid in old browsers */
:root {
  --accent: color-mix(in oklch, #2c3e50 80%, teal);
}

/* right: fallback on the consumer, not the producer */
:root {
  --accent: #2f4858; /* static approximation */
}
@supports (color: color-mix(in srgb, red, blue)) {
  :root {
    --accent: color-mix(in oklch, #2c3e50 80%, teal);
  }
}

This two-value pattern on the custom property itself is the safest. It keeps the mixed value inside the @supports block. An unsupported browser never sees it. The static value remains valid. The computed value of the custom property in an old browser is the static hex. In a modern browser it is the mixed oklch value. That is the difference between a design system that survives an old Android WebView and one that silently drops every component using the variable.

Contrast Ratio Calculations for Every Mixed Color

The WCAG 2.2 contrast ratio is a number between 1 and 21 that compares the relative luminance of two colors. For each mixed color shown, the ratio is computed against the background it sits on. On a #fafafa background (the page default), the mixed card background oklch(0.381 0.066 0.009) has a contrast ratio of 8.1:1, passing AAA for normal text. The fallback #4a5d6e on the same background has a ratio of 6.3:1, passing AA but not AAA. Acceptable for a fallback. The hover color color-mix(in oklch, #2c3e50 85%, black) computes to oklch(0.303 0.055 0.008) and has a ratio of 11.4:1 against white, well above the 7:1 AAA threshold. The disabled color color-mix(in oklch, #2c3e50 60%, white) is oklch(0.574 0.071 0.010) with a ratio of 3.2:1. That fails even AA for normal text. Use it only for non-text elements like disabled button backgrounds, not for text.

/* reference: how to compute these ratios yourself */
/* use a color contrast tool, or calculate from relative luminance */
/* relative luminance: Y = 0.2126R + 0.7152G + 0.0722B, each channel gamma-corrected */
/* contrast = (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter color */

For the accent example color-mix(in oklch, #2c3e50 80%, teal), which computes to oklch(0.458 0.103 0.016), the contrast against #fafafa is 5.8:1, passing AA but not AAA. Need a higher-contrast accent for text? Reduce the teal proportion or use a darker teal. The numbers here are exact for the colors shown. For your own mixes, run them through a contrast calculator before choosing a value for a text foreground. A color that passes on a white background will not necessarily pass on a dark one. Test against the actual background where the color will be used.

Common Mistakes and How to Avoid Them

Three Production Errors

The three mistakes named earlier are the ones that appear in production code. First, comma-separated colors. This comes from familiarity with older color functions like rgb(255, 0, 0) or linear-gradient(to right, red, blue). color-mix() uses a comma to separate the interpolation method from the color list. Inside that list, a space is the separator. Second, percentage-before-color. Writing color-mix(in srgb, 50% red, blue) is invalid. Third, and subtler: when you give only the first color a percentage, the second defaults to the remainder. But only if you give the second no percentage at all. Write red 70%, blue 40% and the sum is 110%. The normalization rule kicks in, producing red 63.6%, blue 36.4%, not the 70/30 you intended. The rule: omit the second percentage if you want the remainder, or specify both and keep the sum at 100.

/* wrong */
color-mix(in srgb, red, blue); /* comma between colors */
color-mix(in srgb, 50% red, blue); /* percentage before color */
color-mix(in srgb, red 70%, blue 40%); /* sum > 100% */

/* right */
color-mix(in srgb, red 70%, blue); /* second defaults to 30% */
color-mix(in srgb, red 70%, blue 30%); /* explicit, sum = 100% */

The Sass Migration Trap

A fourth mistake appears when people port Sass mix() code. Sass uses a different argument order and a different default. Sass mix(#2c3e50, white, 15%) means 15% of white. color-mix(in oklch, #2c3e50 85%, white) means 85% of the first color and 15% of the second. The percentage in color-mix() applies to the color it follows, not the second color. Migrating from a preprocessor? Write the percentages explicitly on both sides and test the output. The perceptually uniform space can shift the visual result even when the numbers look the same.

Interop Quirks and Engine Differences

Every engine that supports color-mix() implements the same formal syntax. Small differences exist in how they handle edge cases. First, gamut mapping: when a mixed color falls outside the display gamut, each engine clamps or maps it slightly differently. Chrome and Firefox both use a simple clip in srgb for out-of-gamut values. Safari historically used a different mapping for Display P3. In practice, this matters only if you are mixing colors near the edge of the sRGB gamut and then outputting to a wide-gamut display. Second, the currentColor keyword: using color-mix(in srgb, currentColor, black) works in all supporting engines. The computed value depends on the element’s own color property, which can lead to different results in nested contexts. Third, transparent: mixing with transparent in oklch interpolates the alpha channel as well as the color channels. This can produce a color with a mid-alpha value that composites differently than a pure opacity change.

/* edge case: currentColor mixes at use time */
.parent {
  color: #2c3e50;
  --ring: color-mix(in srgb, currentColor 80%, black);
}
.child {
  color: teal; /* inherits --ring but currentColor is now teal */
  border: 2px solid var(--ring); /* mixes teal, not #2c3e50 */
}

The currentColor behavior is defined in the spec. It surprises people who expect a custom property to freeze the value. It does not. Custom properties resolve at computed-value time. currentColor resolves against the element where the property is used, not where it is defined. This is a feature, not a bug. It means you cannot use currentColor inside a color-mix() in a custom property and expect it to capture the defining element’s color. Need that? Define the color explicitly or use a different approach.

Frequently Asked Questions

What does color-mix() do that opacity does not? Opacity changes the alpha channel of a single color, which lets the background show through. The result depends on the background. color-mix() computes a new opaque color that is independent of the background. It looks the same on any surface. That is the core difference.

Can I use color-mix() in a gradient? Yes, anywhere a <color> is accepted. Mix a base color with a transparent version to create a fade, or mix two colors and use the result as a gradient stop.

What is the default color space if I omit the in clause? There is no default. The in clause is required. Omitting it is a syntax error. The common choices are srgb, oklch, and hsl.

Does color-mix() work with var() inside it? Yes, as long as the custom property resolves to a valid color. If it resolves to an invalid value, the whole color-mix() becomes invalid at computed-value time.

What happens when I mix a color with transparent? The result is a color with an alpha value between the two. In oklch, the interpolation also affects the chroma and lightness. The result is not the original color with reduced opacity.

Is color-mix() the same as Sass mix()? No. The percentage applies to the color it follows, and the color space is explicit. Sass mix() uses a different default space and a different percentage interpretation.

Do I need a fallback for browsers that do not support it? Yes, if any of your users are on device-locked browsers from before 2023. Use the two-value @supports pattern described above.

color-mix() vs the Alternatives

Use case color-mix() Opacity layering Sass mix() light-dark()
Result is background-independent Yes, opaque computed value No, depends on background Yes, but static No, picks one of two
Works at runtime with custom properties Yes Yes No, compile-time Yes
Perceptually uniform option Yes, via oklch No No No
Needs a build step No No Yes No
Browser support (2026) Widely available Widely available N/A (compile-time) Widely available
Best for Dynamic tints, theme tokens Simple overlays on fixed backgrounds Legacy design systems Light/dark theme switching

The table shows that color-mix() is the only option that combines runtime computation, a perceptually uniform color space, and no build step. Opacity layering is the cheapest to write but the most fragile. Sass mix() is a build-time operation. It cannot respond to a custom property change after the page loads. light-dark() is a different tool entirely. It does not mix. It selects between two predefined values based on the user’s color scheme preference.

When color-mix() Is Not the Answer

The honest caveat: color-mix() is not a replacement for every color operation. Need to adjust a single color’s lightness or saturation without a partner color? Mixing with black or white works, but functions like oklch() with direct lightness or chroma arguments are more explicit. Need to animate a color smoothly? color-mix() cannot interpolate between two mixed results on its own. Set up a CSS transition between two color-mix() values. It works but is verbose. For the specific problem of mixing two colors dynamically and getting a background-independent result, color-mix() is the right tool. The Baseline status means you can use it without apology. But if you find yourself writing color-mix() purely to avoid defining a custom property, step back. A named custom property with an explicit color is clearer than a computed mix, especially for a design system that other people will read.