Mix-blend-mode: how CSS blends layers and how to fix text contrast in photos

Situation familiar to everyone who has built a site with editorial layout. In the hero block there is a photo, on top of it — a white logo and a headline. In the first picture everything is fine: the sky is dark, the white text is readable. Then the content manager uploads a photo of a snowy field, and the logo disappears. The classic answer is to put a semi-transparent black chip over the photo. It works, but it also drowns out the photo itself: it wasn’t chosen to be filled with gray.

There is another way — instead of dimming the background, make the text itself respond to what is under it. In CSS there is mix-blend-mode for this, and this is a case where one property solves the task that is usually tackled with a script that analyzes image brightness.

What actually happens when the browser blends two layers

Blending is a canonical function of two numbers: the color of the backdrop (in the spec it is called Cb, backdrop) and the color of the overlaid layer (Cs, source). Both are normalized to a range from 0 to 1, the function is calculated separately for red, green and blue. Twelve of the sixteen modes are arranged exactly like this — they are called separable, because the channels are computed independently. normal is trivial (takes the top color), soft-light has a piecewise formula and doesn’t fit in a single line of a table, the other ten look like this:

Mode Formula from the specification
multiply B = Cb × Cs
screen B = 1 − (1 − Cb) × (1 − Cs)
darken B = min(Cb, Cs)
lighten B = max(Cb, Cs)
color-dodge B = min(1, Cb / (1 − Cs))
color-burn B = 1 − min(1, (1 − Cb) / Cs)
hard-light multiply or screen depending on whether Cs is darker than 50%
overlay the same as hard-light, but the layers are swapped
difference B = abs(Cb − Cs)
exclusion B = Cb + Cs − 2 × Cb × Cs

The remaining four — hue, saturation, color, luminosity — are non-separable: they dissect the color into hue, saturation and lightness and take different components from different layers. color, for example, takes the hue and saturation from the top, and the lightness from the bottom — which is why it paints black-and-white photos.

It’s easier to see all sixteen on one image. Below, the white word #fff sits on top of the same file, only the mix-blend-mode changes:


Render in Chromium 141, markup and text color in all tiles are the same

Info:

In the tiles multiply, darken and color-burn the text disappears not by mistake: for all three white is the neutral element, the formula reduces to B = Cb, i.e. the backdrop unchanged. For hue, saturation and color the reason is different: a white source carries neither tone nor saturation, so the output keeps the backdrop, but its color has been taken away — on light areas the difference is almost indistinguishable. If a mode “doesn’t work,” first check not the browser, but whether you fed a neutral color as input.

Why take two out of the sixteen for contrast

The idea with which people usually start exploring the topic was briefly formulated by Cody Lindsay Gordon: instead of backdrops, put the logo in with mix-blend-mode: exclusion, and it will adjust itself to the brightness of the photo.

.blend-me {
  mix-blend-mode: exclusion;
}

The mechanism is visible directly from the formulas. For difference, with a white source B = |Cb − 1| = 1 − Cb: a dark background yields light text, a light background yields dark. The inversion happens by itself, with no single line of JavaScript and no histogram analysis. For exclusion the result is the same in sign, but softer in midtones — the image under the text remains more visible.

An important detail from the same note, which you stumble on first: the layer you want to blend with must lie under the element and be adjacent to it, not a parent.

<h1 class="blend-me">Hello world</h1>
<figure>
  <img src="/hero.jpg" alt="">
</figure>

And where do these two modes break

Now the unpleasant thing. Substitute the formula with difference the background exactly in the middle of the scale: |0.5 − 1| = 0.5. The result matches the background — the text dissolved. The same with exclusion: 0.5 + 1 − 2 × 0.5 × 1 = 0.5. Both modes yield zero contrast exactly where the photo is mid-gray, which is the most common brightness in real images.


The same caption over the brightness scale from 0% to 100%, render in Chromium 141

Error:

Setting mix-blend-mode: difference on the main interface text — and consider the contrast issue closed. WCAG requires 4.5:1 for regular text and 3:1 for large text (from 24 px, or from 18.66 px bold). The result of blending depends on the image that the editor loads, so guaranteeing these numbers in advance is not possible in principle. Checking “by eye on a single photo” is not testing.

From here a practical rule: blending works great for large decorative typography, monograms, digits, dividers and hover states — i.e., where loss of readability does not break the scenario. For captions, buttons, and paragraphs you need a predictable background.

Decision matrix: which mode for which task

Task Mode What to consider
Large logo or title over an arbitrary photo difference, exclusion lack of contrast on mid-gray areas
Darken the image for white text multiply with a dark color predictable, but it fades the photo itself
Lighten the image for dark text screen with a light color eats details in shadows
Duotone, consistent style for all covers color or luminosity computed once, can be cached in CSS
Overlay noise or paper texture multiply, soft-light keep the texture fine, its weight goes into traffic
Glow, flares, particles screen, plus-lighter plus-lighter is in Chromium 141, plus-darker is not
Recolor black-and-white icon color on a colored source the result is unpredictable
Highlight the area under the cursor difference on the cursor element property doesn’t animate; animate the position
Important:

mix-blend-mode does not animate — MDN states: Animation type: Not animatable. Toggling it in @keyframes is pointless; animate what sits under it and above it — position, size, color.

Trap one: the element blends with not the whole page

The most common complaint sounds like “I have the same thing, but it doesn’t work.” The reason is almost always one: blending is not with everything drawn below, but only with the content of the nearest overlay context (stacking context). If a wrapper appears between the text and the image, creating its own context, blending stops having anything to blend with.

And the overlay context creates far more properties than one would memorize: transform, filter, opacity less than one, will-change, position: fixed, contain: paint, an element inside a flex or grid container with a z-index different from auto. The very mix-blend-mode itself also creates a context — on the element where it’s declared.


Markup in all three cards is the same, only one property differs on the wrapper. Render in Chromium 141

Warning:

There is a reverse issue as well. If you don’t create a context at all, the element will blend with everything below it in the DOM — up to the page background and neighboring blocks. Fixing this with a single line on the common parent:

.hero {
  isolation: isolate;
}

isolation: isolate forcibly creates an overlay context and confines blending to the contents of this block. The property is supported by all browsers since January 2020.

Separately, the background on body. The background of the root element and body the browser pushes to the viewport, and in blending it does not participate: an element with mix-blend-mode will blend with transparency, i.e., visually with nothing. The cover image for this article is assembled exactly as needed: the photo lies on an inner stage block that has isolation: isolate, and the headline above it uses difference.

Trap two: background-blend-mode solves another task

A similar name, a different scope. mix-blend-mode blends the element with what’s drawn under it. background-blend-mode blends between the layers of the background of the same element — several background-images and the background-color under them. No extra wrappers or pseudo-elements needed for darkening.


In all four cards the same file in background-image, render in Chromium 141

Duotone is fully contained in four lines:

.duotone {
  background-image: linear-gradient(135deg, #ff2d6f, #00d4ff), url("/hero.jpg");
  background-blend-mode: color;
  background-size: cover;
  background-position: center;
}

The top layer gives hue and saturation, the bottom — lightness; the result is a uniform aesthetic for the entire cover feed. The order of layers in background-image is top to bottom, i.e. the gradient goes first and the photo last.

Success:

Darkening for white text without any extra markup:

.hero {
  background-image: url("/hero.jpg");
  background-color: #1b2440;
  background-blend-mode: multiply;
  background-size: cover;
}

Next you change only background-color — and get any amount of darkening without a second div, without ::after, and without editing HTML.

What to put into production

The working scheme looks like this: predictable background as a base, blending as an enhancement on top, and testing support via @supports.

.hero {
  position: relative;
  isolation: isolate;
  background: url("/hero.jpg") center / cover;
}

/* base: works always */
.hero__title {
  color: #fff;
  text-shadow: 0 1px 3px rgb(0 0 0 / 0.75);
}

.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgb(0 0 0 / 0.55), transparent 60%);
}

/* enhancement: only for large decorative heading */
@supports (mix-blend-mode: difference) {
  .hero__logo {
    mix-blend-mode: difference;
    color: #fff;
  }
}

The logo gets automatic inversion, and the text that must remain readable relies on the gradient background and shadow — their contrast can be calculated in advance and checked with any checker. Check the hero block ready at least on four images: very dark, very bright, mid-gray, and colorful — it is the mid-gray that captures most failures.

Sources

Related topic on the forum: CSS in email reads passwords: study of PortSwigger research and Saw at zero volume: hidden AudioContext on AliExpress.

Question:

Have you allowed mix-blend-mode into a production project — or did you stick to a gradient background? And was there a case when the mode behaved differently in Chromium and Safari: it would be interesting to compile a list of discrepancies caught by hand.