Table of Contents
Let’s discuss the “how” of blend modes
difference
exclusion
Now, let’s turn to the “what” of blend modes
Text state change effect
Invert just an area of an element (or a background)
Gradual inversion
Hollow intersection effect
Ripples and rays
Split screen
More examples
Bring me to life
Closing thoughts
Home Web Front-end CSS Tutorial Taming Blend Modes: `difference` and `exclusion`

Taming Blend Modes: `difference` and `exclusion`

Mar 26, 2025 am 10:53 AM

Taming Blend Modes: `difference` and `exclusion`

Up until 2020, blend modes were a feature I hadn’t used much because I rarely ever had any idea what result they could produce without giving them a try first. And taking the “try it and see what happens” approach seemed to always leave me horrified by the visual vomit I had managed to create on the screen.

The problem stemmed from not really knowing how they work in the back. Pretty much every article I’ve seen on the topic is based on examples, comparisons with Photoshop or verbose artistic descriptions. I find examples great, but when you have no clue how things work in the back, adapting a nice-looking demo into something that would implement a different idea you have in your head becomes a really time-consuming, frustrating and ultimately futile adventure. Then Photoshop comparisons are pretty much useless for someone coming from a technical background. And verbose artistic descriptions feel like penguin language to me.

So I had a lightbulb moment when I came across the spec and found it also includes mathematical formulas according to which blend modes work. This meant I could finally understand how this stuff works in the back and where it can be really useful. And now that I know better, I’ll be sharing this knowledge in a series of articles.

Today, we’ll focus on how blending generally works, then take a closer look at two somewhat similar blend modes — difference and exclusion — and, finally, get to the meat of this article where we’ll dissect some cool use cases like the ones below.

Let’s discuss the “how” of blend modes

Blending means combining two layers (that are stacked one on top of the other) and getting a single layer. These two layers could be two siblings, in which case the CSS property we use is mix-blend-mode. They could also be two background layers, in which case the CSS property we use is background-blend-mode. Note that when I talk about blending “siblings,” this includes blending an element with the pseudo-elements or with the text content or the background of its parent. And when it comes to background layers, it’s not just the background-image layers I’m talking about — the background-color is a layer as well.

When blending two layers, the layer on top is called the source, while the layer underneath is called the destination. This is something I just take as it is because these names don’t make much sense, at least to me. I’d expect the destination to be an output, but instead they’re both inputs and the resulting layer is the output.

How exactly we combine the two layers depends on the particular blend mode used, but it’s always per pixel. For example, the illustration below uses the multiply blend mode to combine the two layers, represented as grids of pixels.

Alright, but what happens if we have more than two layers? Well, in this case, the blending process happens in stages, starting from the bottom.

In a first stage, the second layer from the bottom is our source, and the first layer from the bottom is our destination. These two layers blend together and the result becomes the destination for the second stage, where the third layer from the bottom is the source. Blending the third layer with the result of blending the first two gives us the destination for the third stage, where the fourth layer from the bottom is the source.

Of course, we can use a different blend mode at each stage. For example, we can use difference to blend the first two layers from the bottom, then use multiply to blend the result with the third layer from the bottom. But this is something we’ll go a bit more into in future articles.

The result produced by the two blend modes we discuss here doesn’t depend on which of the two layers is on top. Note that this is not the case for all possible blend modes, but it is the case for the ones we’re looking at in this article.

They are also separable blend modes, meaning the blending operation is performed on each channel separately. Again, this is not the case for all possible blend modes, but it is the case for difference and exclusion.

More exactly, the resulting red channel only depends on the red channel of the source and the red channel of the destination; the resulting green channel only depends on the green channel of the source and the green channel of the destination; and finally, the resulting blue channel only depends on the blue channel of the source and the blue channel of the destination.

R = f<sub>B</sub>(R<sub>s</sub>, R<sub>d</sub>)
G = f<sub>B</sub>(G<sub>s</sub>, G<sub>d</sub>)
B = f<sub>B</sub>(B<sub>s</sub>, B<sub>d</sub>)
Copy after login

For a generic channel, without specifying whether it’s red, green or blue, we have that it’s a function of the two corresponding channels in the source (top) layer and in the destination (bottom) layer:

Ch = f<sub>B</sub>(Ch<sub>s</sub>, Ch<sub>d</sub>)
Copy after login

Something to keep in mind is that RGB values can be represented either in the [0, 255] interval, or as percentages in the [0%, 100%] interval, and what we actually use in our formulas is the percentage expressed as a decimal value. For example, crimson can be written as either rgb(220, 20, 60) or as rgb(86.3%, 7.8%, 23.5%) — both are valid. The channel values we use for computations if a pixel is crimson are the percentages expressed as decimal values, that is .863, .078, .235.

If a pixel is black, the channel values we use for computations are all 0, since black can be written as rgb(0, 0, 0) or as rgb(0%, 0%, 0%). If a pixel is white, the channel values we use for computations are all 1, since white can be written as rgb(255, 255, 255) or as rgb(100%, 100%, 100%).

Note that wherever we have full transparency (an alpha equal to 0), the result is identical to the other layer.

difference

The name of this blend mode might provide a clue about what the blending function fB() does. The result is the absolute value of the difference between the corresponding channel values for the two layers.

Ch = f<sub>B</sub>(Ch<sub>s</sub>, Ch<sub>d</sub>) = |Chs - Chd|
Copy after login

First off, this means that if the corresponding pixels in the two layers have identical RGB values (i.e. Chs = Chd for every one of the three channels), then the resulting layer’s pixel is black since the differences for all three channels are 0.

Chs = Chd
Ch = f<sub>B</sub>(Ch<sub>s</sub>, Ch<sub>d</sub>) = |Chs - Chd| = 0
Copy after login

Secondly, since the absolute value of the difference between any positive number and 0 leaves that number unchanged, it results in the corresponding result pixel having the same RGB value as the other layer’s pixel if a layer’s pixel is black (all channels equal 0).

If the black pixel is in the top (source) layer, replacing its channel values with 0 in our formula gives us:

Ch = f<sub>B</sub>(0, Ch<sub>d</sub>) = |0 - Ch<sub>d</sub>| = |-Ch<sub>d</sub>| = Ch<sub>d</sub>
Copy after login

If the black pixel is in the bottom (destination) layer, replacing its channel values with 0 in our formula gives us:

Ch = f<sub>B</sub>(Ch<sub>s</sub>, 0) = |Ch<sub>s</sub> - 0| = |Ch<sub>s</sub>| = Ch<sub>s</sub>
Copy after login

Finally, since the absolute value of the difference between any positive subunitary number and 1 gives us the complement of that number, it results that if a layer’s pixel is white (has all channels 1), the corresponding result pixel is the other layer’s pixel fully inverted (what filter: invert(1) would do to it).

If the white pixel is in the top (source) layer, replacing its channel values with 1 in our formula gives us:

Ch = f<sub>B</sub>(1, Ch<sub>d</sub>) = |1 - Ch<sub>d</sub>| = 1 - Ch<sub>d</sub>
Copy after login

If the white pixel is in the bottom (destination) layer, replacing its channel values with 1 in our formula gives us:

Ch = f<sub>B</sub>(Ch<sub>s</sub>, 1) = |Ch<sub>s</sub> - 1| = 1 - Ch<sub>s</sub>
Copy after login

This can be seen in action in the interactive Pen below, where you can toggle between viewing the layers separated and viewing them overlapping and blended. Hovering the three columns in the overlapping case also reveals what’s happening for each.

exclusion

For the second and last blend mode we’re looking at today, the result is twice the product of the two channel values, subtracted from their sum:

Ch = f<sub>B</sub>(Ch<sub>s</sub>, Ch<sub>d</sub>) = Chs   Chd - 2·Chs·Chd
Copy after login

Since both values are in the [0, 1] interval, their product is always at most equal to the smallest of them, so twice the product is always at most equal to their sum.

If we consider a black pixel in the top (source) layer, then replace Chs with 0 in the formula above, we get the following result for the corresponding result pixel’s channels:

Ch = f<sub>B</sub>(0, Ch<sub>d</sub>) = 0   Ch<sub>d</sub> - 2·0·Ch<sub>d</sub> = Ch<sub>d</sub> - 0 = Ch<sub>d</sub>
Copy after login

If we consider a black pixel in the bottom (destination) layer, then replace Chd with 0 in the formula above, we get the following result for the corresponding result pixel’s channels:

Ch = f<sub>B</sub>(Ch<sub>s</sub>, 0) = Ch<sub>s</sub>   0 - 2·Ch<sub>s</sub>·0 = Ch<sub>s</sub> - 0 = Ch<sub>s</sub>
Copy after login

So, if a layer’s pixel is black, it results that the corresponding result pixel is identical to the other layer’s pixel.

If we consider a white pixel in the top (source) layer, then replace Chs with 1 in the formula above, we get the following result for the corresponding result pixel’s channels:

Ch = f<sub>B</sub>(1, Ch<sub>d</sub>) = 1   Ch<sub>d</sub> - 2·1·Ch<sub>d</sub> = 1   Ch<sub>d</sub> - 2·Ch<sub>d</sub> = 1 - Ch<sub>d</sub>
Copy after login

If we consider a white pixel in the bottom (destination) layer, then replace Chd with 1 in the formula above, we get the following result for the corresponding result pixel’s channels:

Ch = f<sub>B</sub>(Ch<sub>s</sub>, 1) = Ch<sub>s</sub>   1 - 2·Ch<sub>s</sub>·1 = Ch<sub>s</sub>   1 - 2·Ch<sub>s</sub> = 1 - Ch<sub>s</sub>
Copy after login

So if a layer’s pixel is white, it results that the corresponding result pixel is identical to the other layer’s pixel inverted.

This is all shown in the following interactive demo:

Note that as long as at least one of the layers only has black and white pixels, difference and exclusion produce the exact same result.

Now, let’s turn to the “what” of blend modes

Here comes the interesting part — the examples!

Text state change effect

Let’s say we have a paragraph with a link:

<p>Hello, <a href="#">World</a>!</p>
Copy after login

We start by setting a few basic styles to put our text in the middle of the screen, bump up its font-size, set a background on the body and a color on both the paragraph and the link.

body {
  display: grid;
  place-content: center;
  height: 100vh;
  background: #222;
  color: #ddd;
  font-size: clamp(1.25em, 15vw, 7em);
}

a { color: gold; }
Copy after login

Doesn’t look like much so far, but we’ll soon change that!

The next step is to create an absolutely positioned pseudo-element that covers the entire link and has its background set to currentColor.

a {
  position: relative;
  color: gold;
  
  &::after {
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    left: 0;
    background: currentColor;
    content: '';
  }
}
Copy after login

The above looks like we’ve messed things up… but have we really? What we have here is a gold rectangle on top of gold text. And if you’ve paid attention to how the two blend modes discussed above work, then you’ve probably already guessed what’s next — we blend the two sibling nodes within the link (the pseudo-element rectangle and the text content) using difference, and since they’re both gold, it results that what they have in common — the text — becomes black.

p { isolation: isolate; }

a {
  /* same as before */
  
  &::after {
    /* same as before */
    mix-blend-mode: difference;
  }
}
Copy after login

Note that we have to isolate the paragraph to prevent blending with the body background. While this is only an issue in Firefox (and given we have a very dark background on the body, it’s not too noticeable) and is fine in Chrome, keep in mind that, according to the spec, what Firefox does is actually correct. It’s Chrome that’s behaving in a buggy way here, so we should have the isolation property set in case the bug gets fixed.

Alright, but we want this to happen only if the link is focused or hovered. Otherwise, the pseudo-element isn’t visible — let’s say it’s scaled down to nothing.

a {
  /* same as before */
  text-decoration: none;
  
  &::after {
    /* same as before */
    transform: scale(0);
  }

  &:focus { outline: none }
  &:focus, &:hover { &::after { transform: none; } }
}
Copy after login

We’ve also removed the link underline and the focus outline. Below, you can now see the difference effect on :hover (the same effect occurs on :focus, which is something you can test in the live demo).

We now have our state change, but it looks rough, so let’s add a transition!

a {
  /* same as before */
  
  &::after {
    /* same as before */
    transition: transform .25s;
  }
}
Copy after login

Much better!

It would look even better if our pseudo grew not from nothing in the middle, but from a thin line at the bottom. This means we need to set the transform-origin on the bottom edge (at 100% vertically and whatever value horizontally) and initially scale our pseudo to something slightly more than nothing along the y axis.

a {
  /* same as before */
  
  &::after {
    /* same as before */
    transform-origin: 0 100%;
    transform: scaleY(.05);
  }
}
Copy after login

Something else I’d like to do here is replace the font of the paragraph with a more aesthetically appealing one, so let’s take care of that too! But we now have a different kind of problem: the end of the ‘d’ sticks out of the rectangle on :focus/:hover.

We can fix this with a horizontal padding on our link.

a {
  /* same as before */
  padding: 0 .25em;
}
Copy after login

In case you’re wondering why we’re setting this padding on both the right and the left side instead of just setting a padding-right, the reason is illustrated below. When our link text becomes “Alien World,” the curly start of the ‘A’ would end up outside of our rectangle if we didn’t have a padding-left.

This demo with a multi-word link above also highlights another issue when we reduce the viewport width.

One quick fix here would be to set display: inline-block on the link. This isn’t a perfect solution. It also breaks when the link text is longer than the viewport width, but it works in this particular case, so let’s just leave it here now and we’ll come back to this problem in a little while.

Let’s now consider the situation of a light theme. Since there’s no way to get white instead of black for the link text on :hover or :focus by blending two identical highlight layers that are both not white, we need a bit of a different approach here, one that doesn’t involve using just blend modes.

What we do in this case is first set the background, the normal paragraph text color, and the link text color to the values we want, but inverted. I was initially doing this inversion manually, but then I got the suggestion of using the Sass invert() function, which is a very cool idea that really simplifies things. Then, after we have this dark theme that’s basically the light theme we want inverted, we get our desired result by inverting everything again with the help of the CSS invert() filter function.

Tiny caveat here: we cannot set filter: invert(1) on the body or html elements because this is not going to behave the way we expect it to and we won’t be getting the desired result. But we can set both the background and the filter on a wrapper around our paragraph.

<section>
  <p>Hello, <a href="#">Alien World</a>!</p>
</section>
Copy after login
body {
  /* same as before, 
     without the place-content, background and color declarations, 
     which we move on the section */
}

section {
  display: grid;
  place-content: center;
  background: invert(#ddd) /* Sass invert(<color>) function */;
  color: invert(#222); /* Sass invert<color>) function */;
  filter: invert(1); /* CSS filter invert(<number>) function */
}

a {
  /* same as before */
  color: invert(purple); /* Sass invert(<color>) function */
}</color></number></color></color>
Copy after login

Here’s an example of a navigation bar employing this effect (and a bunch of other clever tricks, but those are outside the scope of this article). Select a different option to see it in action:

Something else we need to be careful with is the following: all descendants of our section get inverted when we use this technique. And this is probably not what we want in the case of img elements — I certainly don’t expect to see the images in a blog post inverted when I switch from the dark to the light theme. Consequently, we should reverse the filter inversion on every img descendant of our section.

section {
  /* same as before */
  
  &, & img { filter: invert(1); }
}
Copy after login

Putting it all together, the demo below shows both the dark and light theme cases with images:

Now let’s get back to the wrapping link text issue and see if we don’t have better options than making the a elements inline-block ones.

Well, we do! We can blend two background layers instead of blending the text content and a pseudo. One layer gets clipped to the text, while the other one is clipped to the border-box and its vertical size animates between 5% initially and 100% in the hovered and focused cases.

a {
  /* same as before */
  -webkit-text-fill-color: transparent;
     -moz-text-fill-color: transparent;
  --full: linear-gradient(currentColor, currentColor);
  background: 
    var(--full), 
    var(--full) 0 100%/1% var(--sy, 5%) repeat-x;
  -webkit-background-clip: text, border-box;
          background-clip: text, border-box;
  background-blend-mode: difference;
  transition: background-size .25s;
	
  &:focus, &:hover { --sy: 100%; }
}
Copy after login

Note that we don’t even have a pseudo-element anymore, so we’ve taken some of the CSS on it, moved it on the link itself, and tweaked it to suit this new technique. We’ve switched from using mix-blend-mode to using background-blend-mode; we’re now transitioning background-size of transform and, in the :focus and :hover states; and we’re now changing not the transform, but a custom property representing the vertical component of the background-size.

Much better, though this isn’t a perfect solution either.

The first problem is one you’ve surely noticed if you checked the caption’s live demo link in Firefox: it doesn’t work at all. This is due to a Firefox bug I apparently reported back in 2018, then forgot all about until I started toying with blend modes and hit it again.

The second problem is one that’s noticeable in the recording. The links seem somewhat faded. This is because, for some reason, Chrome blends inline elements like links (note that this won’t happen with block elements like divs) with the background of their nearest ancestor (the section in this case) if these inline elements have background-blend-mode set to anything but normal.

Even more weirdly, setting isolation: isolate on the link or its parent paragraph doesn’t stop this from happening. I still had a nagging feeling it must have something to do with context, so I decided to keep throwing possible hacks at it, and hope maybe something ends up working. Well, I didn’t have to spend much time on it. Setting opacity to a subunitary (but still close enough to 1 so it’s not noticeable that it’s not fully opaque) value fixes it.

a {
  /* same as before */
  opacity: .999; /* hack to fix blending issue ¯_(ツ)_/¯ */
}
Copy after login

The final problem is another one that’s noticeable in the recording. If you look at the ‘r’ at the end of “Amur” you can notice its right end is cut out as it falls outside the background rectangle. This is particularly noticeable if you compare it with the ‘r’ in “leopard.”

I didn’t have high hopes for fixing this one, but threw the question to Twitter anyway. And what do you know, it can be fixed! Using box-decoration-break in combination with the padding we have already set can help us achieve the desired effect!

a {
  /* same as before */
  box-decoration-break: clone;
}
Copy after login

Note that box-decoration-break still needs the -webkit- prefix for all WebKit browsers, but unlike in the case of properties like background-clip where at least one value is text, auto-prefixing tools can take care of the problem just fine. That’s why I haven’t included the prefixed version in the code above.

Another suggestion I got was to add a negative margin to compensate for the padding. I’m going back and forth on this one — I can’t decide whether I like the result better with or without it. In any event, it’s an option worth mentioning.

$p: .25em;

a {
  /* same as before */
  margin: 0 (-$p); /* we put it within parenthesis so Sass doesn't try to perform subtraction */
  padding: 0 $p;
}
Copy after login

Still, I have to admit that animating just the background-position or the background-size of a gradient is a bit boring. But thanks to Houdini, we can now get creative and animate whatever component of a gradient we wish, even though this is only supported in Chromium at the moment. For example, the radius of a radial-gradient() like below or the progress of a conic-gradient().

Invert just an area of an element (or a background)

This is the sort of effect I often see achieved by either using element duplication — the two copies are layered one on top of the other, where one of them has an invert filter and clip-path is used on the top one in order to show both of layers. Another route is layering a second element with an alpha low enough you cannot even tell it’s there and a backdrop-filter.

Both these approaches get the job done if we want to invert a part of the entire element with all its content and descendants, but they cannot help us when we want to invert just a part of the background — both filter and backdrop-filter affect entire elements, not just their backgrounds. And while the new filter() function (already supported by Safari) does have effect solely on background layers, it affects the entire area of the background, not just a part of it.

This is where blending comes in. The technique is pretty straightforward: we have a background layer, part of which we want to invert and one or more gradient layers that give us a white area where we want inversion of the other layer and transparency (or black) otherwise. Then we blend using one of the two blend modes discussed today. For the purpose of inversion, I prefer exclusion (it’s one character shorter than difference).

Here’s a first example. We have a square element that has a two-layer background. The two layers are a picture of a cat and a gradient with a sharp transition between white and transparent.

div {
  background: 
    linear-gradient(45deg, white 50%, transparent 0), 
    url(cat.jpg) 50%/ cover;
}
Copy after login

This gives us the following result. We’ve also set dimensions, a border-radius, shadows, and prettified the text in the process, but all that stuff isn’t really important in this context:

Next, we just need one more CSS declaration to invert the lower left half:

div {
  /* same as before */
  background-blend-mode: exclusion; /* or difference, but it's 1 char longer */
}
Copy after login

Note how the text is not affected by inversion; it’s only applied to the background.

You probably know the interactive before-and-after image sliders. You may have even seen something of the kind right here on CSS-Tricks. I’ve seen it on Compressor.io, which I often use to compress images, including the ones used in these articles!

Our goal is to create something of the kind using a single HTML element, under 100 bytes of JavaScript — and not even much CSS!

Our element is going to be a range input. We don’t set its min or max attributes, so they default to 0 and 100, respectively. We don’t set the value attribute either, so it defaults to 50, which is also the value we give a custom property, --k, set in its style attribute.

<input type="range" style="--k: 50">
Copy after login

In the CSS, we start with a basic reset, then we make our input a block element that occupies the entire viewport height. We also give dimensions and dummy backgrounds to its track and thumb just so that we can start seeing stuff on the screen right away.

$thumb-w: 5em;

@mixin track() {
  border: none;
  width: 100%;
  height: 100%;
  background: url(flowers.jpg) 50%/ cover;
}

@mixin thumb() {
  border: none;
  width: $thumb-w;
  height: 100%;
  background: purple;
}

* {
  margin: 0;
  padding: 0;
}

[type='range'] {
  &, &::-webkit-slider-thumb, 
  &::-webkit-slider-runnable-track { -webkit-appearance: none; }
  
  display: block;
  width: 100vw; height: 100vh;
  
  &::-webkit-slider-runnable-track { @include track; }
  &::-moz-range-track { @include track; }
  
  &::-webkit-slider-thumb { @include thumb; }
  &::-moz-range-thumb { @include thumb; }
}
Copy after login

The next step is to add another background layer on the track, a linear-gradient one where the separation line between transparent and white depends on the current range input value, --k, and then blend the two.

@mixin track() {
  /* same as before */
  background:
    url(flowers.jpg) 50%/ cover, 
    linear-gradient(90deg, transparent var(--p), white 0);
  background-blend-mode: exclusion;
}

[type='range'] {
  /* same as before */
  --p: calc(var(--k) * 1%);
}
Copy after login

Note that the order of the two background layers of the track doesn’t matter as both exclusion and difference are commutative.

It’s starting to look like something, but dragging the thumb does nothing to move the separation line. This is happening because the current value, --k (on which the gradient’s separation line position, --p, depends), doesn’t get automatically updated. Let’s fix that with a tiny bit of JavaScript that gets the slider value whenever it changes then sets --k to this value.

addEventListener('input', e => {
  let _t = e.target;
  _t.style.setProperty('--k',  _t.value)
})
Copy after login

Now all seems to be working fine!

But is it really? Let’s say we do something a bit fancier for the thumb background:

$thumb-r: .5*$thumb-w;
$thumb-l: 2px;

@mixin thumb() {
  /* same as before */
  --list: #fff 0% 60deg, transparent 0%;
  background: 
    conic-gradient(from 60deg, var(--list)) 0/ 37.5% /* left arrow */, 
    conic-gradient(from 240deg, var(--list)) 100%/ 37.5% /* right arrow */, 
    radial-gradient(circle, 
      transparent calc(#{$thumb-r} - #{$thumb-l} - 1px) /* inside circle */, 
      #fff calc(#{$thumb-r} - #{$thumb-l}) calc(#{$thumb-r} - 1px) /* circle line */, 
      transparent $thumb-r /* outside circle */), 
    linear-gradient(
      #fff calc(50% - #{$thumb-r}   .5*#{$thumb-l}) /* top line */, 
      transparent 0 calc(50%   #{$thumb-r} - .5*#{$thumb-l}) /* gap behind circle */, 
      #fff 0 /* bottom line */) 50% 0/ #{$thumb-l};
  background-repeat: no-repeat;
}
Copy after login

The linear-gradient() creates the thin vertical separation line, the radial-gradient() creates the circle, and the two conic-gradient() layers create the arrows.

The problem is now obvious when dragging the thumb from one end to the other: the separation line doesn’t remain fixed to the thumb’s vertical midline.

When we set --p to calc(var(--k)*1%), the separation line moves from 0% to 100%. It should really be moving from a starting point that’s half a thumb width, $thumb-r, until half a thumb width before 100%. That is, within a range that’s 100% minus a thumb width, $thumb-w. We subtract a half from each end, so that’s a whole thumb width to be subtracted. Let’s fix that!

--p: calc(#{$thumb-r}   var(--k) * (100% - #{$thumb-w}) / 100);
Copy after login

Much better!

But the way range inputs work, their border-box moving within the limits of the track’s content-box (Chrome) or within the limits of the actual input’s content-box (Firefox)… this still doesn’t feel right. It would look way better if the thumb’s midline (and, consequently, the separation line) went all the way to the viewport edges.

We cannot change how range inputs work, but we can make the input extend outside the viewport by half a thumb width to the left and by another half a thumb width to the right. This makes its width equal to that of the viewport, 100vw, plus an entire thumb width, $thumb-w.

body { overflow: hidden; }

[type='range'] {
  /* same as before */
  margin-left: -$thumb-r;
  width: calc(100vw   #{$thumb-w});
}
Copy after login

A few more prettifying tweaks related to the cursor and that’s it!

A fancier version of this (inspired by the Compressor.io website) is to put the input within a card whose 3D rotation also changes when the mouse moves over it.

We could also use a vertical slider. This is slightly more complex as our only reliable cross-browser way of creating custom styled vertical sliders is to apply a rotation on them, but this would also rotate the background. What we do is set the --p value and these backgrounds on the (not rotated) slider container, then keep the input and its track completely transparent.

This can be seen in action in the demo below, where I’m inverting a photo of me showing off my beloved Kreator hoodie.

We may of course use a radial-gradient() for a cool effect too:

background: 
  radial-gradient(circle at var(--x, 50%) var(--y, 50%), 
    #000 calc(var(--card-r) - 1px), #fff var(--card-r)) border-box, 
  $img 50%/ cover;
Copy after login

In this case, the position given by the --x and --y custom properties is computed from the mouse motion over the card.

The inverted area of the background doesn’t necessarily have to be created by a gradient. It can also be the area behind a heading’s text, as shown in this older article about contrasting text against a background image.

Gradual inversion

The blending technique for inversion is more powerful than using filters in more than one way. It also allows us to apply the effect gradually along a gradient. For example, the left side is not inverted at all, but then we progress to the right all the way to full inversion.

In order to understand how to get this effect, we must first understand how to get the invert(p) effect, where p can be any value in the [0%, 100%] interval (or in the [0, 1] interval if we use the decimal representation).

The first method, which works for both difference and exclusion is setting the alpha channel of our white to p. This can be seen in action in the demo below, where dragging the slider controls the invrsion progress:

In case you’re wondering about the hsl(0, 0%, 100% / 100%) notation, this is now a valid way of representing a white with an alpha of 1, according the spec.

Furthermore, due to the way filter: invert(p) works in the general case (that is, scaling every channel value to a squished interval [Min(p, q), Max(p, q)]), where q is the complement of p (or q = 1 - p) before inverting it (subtracting it from 1), we have the following for a generic channel Ch when partly inverting it:

1 - (q   Ch·(p - q)) = 
= 1 - (1 - p   Ch·(p - (1 - p))) = 
= 1 - (1 - p   Ch·(2·p - 1)) = 
= 1 - (1 - p   2·Ch·p - Ch) = 
= 1 - 1   p - 2·Ch·p   Ch = 
= Ch   p - 2·Ch·p
Copy after login

What we got is exactly the formula for exclusion where the other channel is p! Therefore, we can get the same effect as filter: invert(p) for any p in the [0%, 100%] interval by using the exclusion blend mode when the other layer is rgb(p, p, p).

This means we can have gradual inversion along a linear-gradient() that goes from no inversion at all along the left edge, to full inversion along the right edge), with the following:

background: 
  url(butterfly_blues.jpg) 50%/ cover, 
  linear-gradient(90deg, 
    #000 /* equivalent to rgb(0%, 0%, 0%) and hsl(0, 0%, 0%) */, 
    #fff /* equivalent to rgb(100%, 100%, 100%) and hsl(0, 0%, 100%) */);
background-blend-mode: exclusion;
Copy after login

Note that using a gradient from black to white for gradual inversion only works with the exclusion blend mode and not with the difference. The result produced by difference in this case, given its formula, is a pseudo gradual inversion that doesn’t pass through the 50% grey in the middle, but through RGB values that have each of the three channels zeroed at various points along the gradient. That is why the contrast looks starker. It’s also perhaps a bit more artistic, but that’s not really something I’m qualified to have an opinion about.

Having different levels of inversion across a background doesn’t necessarily need to come from a black to white gradient. It can also come from a black and white image as the black areas of the image would preserve the background-color, the white areas would fully invert it and we’d have partial inversion for everything in between when using the exclusion blend-mode. difference would again give us a starker duotone result.

This can be seen in the following interactive demo where you can change the background-color and drag the separation line between the results produced by the two blend modes.

Hollow intersection effect

The basic idea here is we have two layers with only black and white pixels.

Ripples and rays

Let’s consider an element with two pseudos, each having a background that’s a repeating CSS gradient with sharp stops:

$d: 15em;
$u0: 10%;
$u1: 20%;

div {
  &::before, &::after {
    display: inline-block;
    width: $d;
    height: $d;
    background: repeating-radial-gradient(#000 0 $u0, #fff 0 2*$u0);
    content: '';
  }
  
  &::after {
    background: repeating-conic-gradient(#000 0% $u1, #fff 0% 2*$u1);
  }
}
Copy after login

Depending on the browser and the display, the edges between black and white may look jagged… or not.

Just to be on the safe side, we can tweak our gradients to get rid of this issue by leaving a tiny distance, $e, between the black and the white:

$u0: 10%;
$e0: 1px;
$u1: 5%;
$e1: .2%;

div {
  &::before {
    background: 
      repeating-radial-gradient(
        #000 0 calc(#{$u0} - #{$e0}), 
        #fff $u0 calc(#{2*$u0} - #{$e0}), 
        #000 2*$u0);
  }
  
  &::after {
    background: 
      repeating-conic-gradient(
        #000 0% $u1 - $e1, 
        #fff $u1 2*$u1 - $e1, 
        #000 2*$u1);
  }
}
Copy after login

Then we can place them one on top of the other and set mix-blend-mode to exclusion or difference, as they both produce the same result here.

div {
  &::before, &::after {
    /* same other styles minus the now redundant display */
    position: absolute;
    mix-blend-mode: exclusion;
  }
}
Copy after login

Wherever the top layer is black, the result of the blending operation is identical to the other layer, whether that’s black or white. So, black over black produces black, while black over white produces white.

Wherever the top layer is white, the result of the blending operation is identical to the other layer inverted. So, white over black produces white (black inverted), while white over white produces black (white inverted).

However, depending on the browser, the actual result we see may look as desired (Chromium) or like the ::before got blended with the greyish background we’ve set on the body and then the result blended with the ::after (Firefox, Safari).

The way Chromium behaves is a bug, but that’s the result we want. And we can get it in Firefox and Safari, too, by either setting the isolation property to isolate on the parent div (demo) or by removing the mix-blend-mode declaration from the ::before (as this would ensure the blending operation between it and the body remains the default normal, which means no blending) and only setting it on the ::after (demo).

Of course, we can also simplify things and make the two blended layers be background layers on the element instead of its pseudos. This also means switching from mix-blend-mode to background-blend-mode.

$d: 15em;
$u0: 10%;
$e0: 1px;
$u1: 5%;
$e1: .2%;

div {
  width: $d;
  height: $d;
  background: 
    repeating-radial-gradient(
      #000 0 calc(#{$u0} - #{$e0}), 
      #fff $u0 calc(#{2*$u0} - #{$e0}), 
      #000 2*$u0), 
    repeating-conic-gradient(
      #000 0% $u1 - $e1, 
      #fff $u1 2*$u1 - $e1, 
      #000 2*$u1);;
  background-blend-mode: exclusion;
}
Copy after login

This gives us the exact same visual result, but eliminates the need for pseudo-elements, eliminates the potential unwanted mix-blend-mode side effect in Firefox and Safari, and reduces the amount of CSS we need to write.

Split screen

The basic idea is we have a scene that’s half black and half white, and a white item moving from one side to the other. The item layer and the scene layer get then blended using either difference or exclusion (they both produce the same result).

When the item is, for example, a ball, the simplest way to achieve this result is to use a radial-gradient for it and a linear-gradient for the scene and then animate the background-position to make the ball oscillate.

$d: 15em;

div {
  width: $d;
  height: $d;
  background: 
    radial-gradient(closest-side, #fff calc(100% - 1px), transparent) 
      0/ 25% 25% no-repeat,
    linear-gradient(90deg, #000 50%, #fff 0);
  background-blend-mode: exclusion;
  animation: mov 2s ease-in-out infinite alternate;
}

@keyframes mov { to { background-position: 100%; } }
Copy after login

We can also make the ::before pseudo the scene and the ::after the moving item:

$d: 15em;

div {
  display: grid;
  width: $d;
  height: $d;
  
  &::before, &::after {
    grid-area: 1/ 1;
    background: linear-gradient(90deg, #000 50%, #fff 0);
    content: '';
  }
  
  &::after {
    place-self: center start;
    padding: 12.5%;
    border-radius: 50%;
    background: #fff;
    mix-blend-mode: exclusion;
    animation: mov 2s ease-in-out infinite alternate;
  }
}

@keyframes mov { to { transform: translate(300%); } }
Copy after login

This may look like we’re over-complicating things considering that we’re getting the same visual result, but it’s actually what we need to do if the moving item isn’t just a disc, but a more complex shape, and the motion isn’t just limited to oscillation, but it also has a rotation and a scaling component.

$d: 15em;
$t: 1s;

div {
  /* same as before */
  
  &::after {
    /* same as before */
    /* creating the shape, not detailed here as
       it's outside the scope of this article */
    @include poly;
    /* the animations */
    animation: 
      t $t ease-in-out infinite alternate, 
      r 2*$t ease-in-out infinite, 
      s .5*$t ease-in-out infinite alternate;
  }
}

@keyframes t { to { translate: 300% } }
@keyframes r {
  50% { rotate: .5turn; }
  100% { rotate: 1turn;; }
}
@keyframes s { to { scale: .75 1.25 } }
Copy after login

Note that, while Safari has now joined Firefox in supporting the individual transform properties we’re animating here, these are still behind the Experimental Web Platform features flag in Chrome (which can be enabled from chrome://flags as shown below).

More examples

We won’t be going into details about the “how” behind these demos as the basic idea of the blending effect using exclusion or difference is the same as before and the geometry/animation parts are outside the scope of this article. However, for each of the examples below, there is a link to a CodePen demo in the caption and a lot of these Pens also come with a recording of me coding them from scratch.

Here’s a crossing bars animation I recently made after a Bees & Bombs GIF:

And here’s a looping moons animation from a few years back, also coded after a Bees & Bombs GIF:

We’re not necessarily limited to just black and white. Using a contrast filter with a subunitary value (filter: contrast(.65) in the example below) on a wrapper, we can turn the black into a dark grey and the white into a light grey:

Here’s another example of the same technique:

If we want to make it look like we have a XOR effect between black shapes on a white background, we can use filter: invert(1) on the wrappers of the shapes, like in the example below:

And if we want something milder like dark grey shapes on a light grey background, we don’t go for full inversion, but only for partial one. This means using a subunitary value for the invert filter like in the example below where we use filter: invert(.85):

It doesn’t necessarily have to be something like a looping or loading animation. We can also have a XOR effect between an element’s background and its offset frame. Just like in the previous examples, we use CSS filter inversion if we want the background and the frame to be black and their intersection to be white.

Another example would be having a XOR effect on hovering/ focusing and clicking a close button. The example below shows both night and light theme cases:

Bring me to life

Things can look a bit sad only in black and white, so there are few things we can do to put some life into such demos.

The first tactic would be to use filters. We can break free from the black and white constraint by using sepia() after lowering the contrast (as this function has no effect over pure black or white). Pick the hue using hue-rotate() and then fine tune the result using brightness() and saturate() or contrast().

For example, taking one of the previous black and white demos, we could have the following filter chain on the wrapper:

filter: 
  contrast(.65) /* turn black and white to greys */
  sepia(1) /* retro yellow-brownish tint */
  hue-rotate(215deg) /* change hue from yellow-brownish to purple */
  blur(.5px) /* keep edges from getting rough/ jagged */
  contrast(1.5) /* increase saturation */
  brightness(5) /* really brighten background */
  contrast(.75); /* make triangles less bright (turn bright white dirty) */
Copy after login

For even more control over the result, there’s always the option of using SVG filters.

The second tactic would be to add another layer, one that’s not black and white. For example, in this radioactive pie demo I made for the first CodePen challenge of March, I used a purple ::before pseudo-element on the body that I blended with the pie wrapper.

body, div { display: grid; }

/* stack up everything in one grid cell */
div, ::before { grid-area: 1/ 1; }

body::before { background: #7a32ce; } /* purple layer */

/* applies to both pie slices and the wrapper */
div { mix-blend-mode: exclusion; }

.a2d { background: #000; } /* black wrapper */

.pie {
  background: /* variable size white pie slices */
    conic-gradient(from calc(var(--p)*(90deg - .5*var(--sa)) - 1deg), 
      transparent, 
      #fff 1deg calc(var(--sa)   var(--q)*(1turn - var(--sa))), 
      transparent calc(var(--sa)   var(--q)*(1turn - var(--sa))   1deg));
}
Copy after login

This turns the black wrapper purple and the white parts green (which is purple inverted).

Another option would be blending the entire wrapper again with another layer, this time using a blend mode different from difference or exclusion. Doing so would allow us more control over the result so we’re not limited to just complementaries (like black and white, or purple and green). That, however, is something we’ll have to cover in a future article.

Finally, there’s the option of using difference (and not exclusion) so that we get black where two identical (not necessarily white) layers overlap. For example, the difference between coral and coral is always going to be 0 on all three channels, which means black. This means we can adapt a demo like the offset and XOR frame one to get the following result:

With some properly set transparent borders and background clipping, we can also make this work for gradient backgrounds:

Similarly, we can even have an image instead of a gradient!

Note that this means we also have to invert the image background when we invert the element in the second theme scenario. But that should be no problem, because in this article we’ve also learned how to do that: by setting background-color to white and blending the image layer with it using background-blend-mode: exclusion!

Closing thoughts

Just these two blend modes can help us get some really cool results without resorting to canvas, SVG or duplicated layers. But we’ve barely scratched the surface here. In future articles, we’ll dive into how other blend modes work and what we can achieve with them alone or in combination with previous ones or with other CSS visual effects such as filters. And trust me, the more tricks you have up your sleeve, the cooler the results you’re able to achieve get!

The above is the detailed content of Taming Blend Modes: `difference` and `exclusion`. For more information, please follow other related articles on the PHP Chinese website!

Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn

Hot AI Tools

Undresser.AI Undress

Undresser.AI Undress

AI-powered app for creating realistic nude photos

AI Clothes Remover

AI Clothes Remover

Online AI tool for removing clothes from photos.

Undress AI Tool

Undress AI Tool

Undress images for free

Clothoff.io

Clothoff.io

AI clothes remover

Video Face Swap

Video Face Swap

Swap faces in any video effortlessly with our completely free AI face swap tool!

Hot Tools

Notepad++7.3.1

Notepad++7.3.1

Easy-to-use and free code editor

SublimeText3 Chinese version

SublimeText3 Chinese version

Chinese version, very easy to use

Zend Studio 13.0.1

Zend Studio 13.0.1

Powerful PHP integrated development environment

Dreamweaver CS6

Dreamweaver CS6

Visual web development tools

SublimeText3 Mac version

SublimeText3 Mac version

God-level code editing software (SublimeText3)

Hot Topics

Java Tutorial
1655
14
PHP Tutorial
1252
29
C# Tutorial
1226
24
Google Fonts   Variable Fonts Google Fonts Variable Fonts Apr 09, 2025 am 10:42 AM

I see Google Fonts rolled out a new design (Tweet). Compared to the last big redesign, this feels much more iterative. I can barely tell the difference

How to Create an Animated Countdown Timer With HTML, CSS and JavaScript How to Create an Animated Countdown Timer With HTML, CSS and JavaScript Apr 11, 2025 am 11:29 AM

Have you ever needed a countdown timer on a project? For something like that, it might be natural to reach for a plugin, but it’s actually a lot more

HTML Data Attributes Guide HTML Data Attributes Guide Apr 11, 2025 am 11:50 AM

Everything you ever wanted to know about data attributes in HTML, CSS, and JavaScript.

How to select a child element with the first class name item through CSS? How to select a child element with the first class name item through CSS? Apr 05, 2025 pm 11:24 PM

When the number of elements is not fixed, how to select the first child element of the specified class name through CSS. When processing HTML structure, you often encounter different elements...

Why are the purple slashed areas in the Flex layout mistakenly considered 'overflow space'? Why are the purple slashed areas in the Flex layout mistakenly considered 'overflow space'? Apr 05, 2025 pm 05:51 PM

Questions about purple slash areas in Flex layouts When using Flex layouts, you may encounter some confusing phenomena, such as in the developer tools (d...

A Proof of Concept for Making Sass Faster A Proof of Concept for Making Sass Faster Apr 16, 2025 am 10:38 AM

At the start of a new project, Sass compilation happens in the blink of an eye. This feels great, especially when it’s paired with Browsersync, which reloads

How We Created a Static Site That Generates Tartan Patterns in SVG How We Created a Static Site That Generates Tartan Patterns in SVG Apr 09, 2025 am 11:29 AM

Tartan is a patterned cloth that’s typically associated with Scotland, particularly their fashionable kilts. On tartanify.com, we gathered over 5,000 tartan

See all articles