Taming Blend Modes: `difference` and `exclusion`

Avatar of Ana Tudor
Ana Tudor on

DataStax Astra — Open, multi-cloud stack for modern apps

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.

A few examples of what we can achieve with these two blend modes.

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.

Illustration showing two layers. The top layer is the source, while the bottom one is the destination.
Blending terminology

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.

Illustration showing two corresponding pixels of the two layers being blended, which results in the corresponding pixel of the resulting layer.
How blending two layers works at a pixel level

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.

Illustration showing the process described above.
Blending multiple layers

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 = fB(Rs, Rd)
G = fB(Gs, Gd)
B = fB(Bs, Bd)

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 = fB(Chs, Chd)

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 = fB(Chs, Chd) = |Chs - Chd|

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 = fB(Chs, Chd) = |Chs - Chd| = 0

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 = fB(0, Chd) = |0 - Chd| = |-Chd| = Chd

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

Ch = fB(Chs, 0) = |Chs - 0| = |Chs| = Chs

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 = fB(1, Chd) = |1 - Chd| = 1 - Chd

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

Ch = fB(Chs, 1) = |Chs - 1| = 1 - Chs

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 = fB(Chs, Chd) = Chs + Chd - 2·Chs·Chd

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 = fB(0, Chd) = 0 + Chd - 2·0·Chd = Chd - 0 = Chd

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 = fB(Chs, 0) = Chs + 0 - 2·Chs·0 = Chs - 0 = Chs

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 = fB(1, Chd) = 1 + Chd - 2·1·Chd = 1 + Chd - 2·Chd = 1 - Chd

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 = fB(Chs, 1) = Chs + 1 - 2·Chs·1 = Chs + 1 - 2·Chs = 1 - Chs

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>!</div>

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; }

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

Screenshot of the result after setting the initial styles. The paragraph text is in the middle. Its normal text is while, while the link text is gold.
What we have so far (demo)

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: '';
  }
}
Screenshot of the result after creating and setting a few basic styles on the link pseudo: this now covers the entire link text.
The result we now have with the pseudo-element on the link (demo)

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;
  }
}

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.

Screenshot of the result after blending the link pseudo with the link text. Since they're both gold, the result is black text on gold background.
The mix-blend-mode: difference effect (demo)

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; } }
}

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).

Animated gif. On hover, the gold pseudo rectangle suddenly shows up and is blended with the gold link text using the difference blend mode, thus making the latter black.
The mix-blend-mode: difference effect only on :hover (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;
  }
}

Much better!

Animated gif. On hover, the gold pseudo rectangle smoothly grows from nothing and is blended with the gold link text using the difference blend mode, thus making their intersection black.
The mix-blend-mode: difference effect only on :hover, now smoothed by a transition (demo)

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);
  }
}
Animated gif. On hover, the gold pseudo rectangle smoothly grows from a thin underline to a rectangle covering its link parent's bounding box and is blended with the gold link text using the difference blend mode, thus making their intersection black.
The mix-blend-mode: difference effect only on :hover, now smoothed by a transition between a thin underline and a rectangle containing the link text (demo)

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.

Screenshot illustrating the problem with slanted text — the last letter ends outside of the pseudo-element rectangle.
The problem illustrated: the end of the “d” sticks out when we :focus or :hover the link (demo)

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

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

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.

Screenshot illustrating the problem with only setting a lateral padding in the direction on the slant (right in this case): if our link text becomes 'Alien World', the curly start of the 'A' falls outside the pseudo-element rectangle. This is solved by having a lateral padding on both sides.
Why we have padding on both lateral sides (demo)

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

Animated gif. Shows how in the case of a multi-line link, the pseudo-element is just between the left of the first word in the link text and the last word in the same link text.
The problem with multi-line links (demo)

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.

Animated gif. Shows the inline-block fix in action in this particular case.
The inline-block solution (demo)

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>
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|percentage>) function */
}

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

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); }
}

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%; }
}

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.

Animated gif. Shows the result when we blend two background layers on the actual link: one clipped to text and the other one clipped to border-box.
The background layer blending solution (demo).

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 ¯_(ツ)_/¯ */
}
Animated gif. Shows the result after applying the opacity hackaround.
Result after fixing the blending issue (demo)

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;
}

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.

Animated gif. Shows the result after applying the box-decoration-break solution.
Result after fixing the text clipping issue (demo).

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;
}
Animated gif. Shows the result after setting a negative margin to compensate for the padding.
Result when we have a negative margin compensating for the padding (demo)

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().

Animated gif. Shows a random bubble growing from nothing and being blended with the text of a navigation link every time this is being hovered or focused.
Bubble effect navigation (demo)

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;
}

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:

Screenshot. Shows a square where the photo of a cat is covered in the lower left half (below the main diagonal) by a solid white background.
The two backgrounds layered

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 */
}

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

Screenshot. Shows a square with a cat background, where the lower left half (below the main diagonal) has been inverted (shows the image negative).
Final result (demo)

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'/>

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; }
}
Screenshot. Shows a tall slider with an image background and a tall narrow purple thumb.
What we have so far (demo)

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%);
}

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)
})

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;
}

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);

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});
}

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;

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

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;
Screenshot of the original butterfly image on the left and the gradually inverted one on the right.
Gradual left-to-right inversion (demo)

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.

Screenshot of the gradually inverted butterfly image (using the exclusion blend mode) on the left and the pseudo-gradually inverted one (using the difference blend mode) on the right.
Gradual left-to-right inversion vs. pseudo-inversion (demo)

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);
  }
}

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

Screenshot showing jagged edges between black and white areas in the two gradients.
Jagged edges (demo)

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);
  }
}
Screenshot showing smoothed edges between black and white areas in the two gradients.
Smooth edges (demo)

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;
  }
}

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).

Screenshot collage. On the left, we have the expected black and white result, something like a XOR between the radial gradient generated ripples and the conic gradient generated rays — this is what we get in Chrome. On the right, we have the same result blended with the lightish grey background — this is what we get in Firefox and Safari.
Chromium 87 (left): result looks as desired; Firefox 83 and Safari 14 (right): cloudy from being blended with the body layer (demo)

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;
}

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.

The desired black and white result, something like a XOR between the radial gradient generated ripples and the conic gradient generated rays.
Desired result with no pseudos (demo)
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%; } }
Animated gif. Shows a white ball oscillating left and right and being XORed with the background that's half white (thus making the ball black) and half black (leaving the ball white).
Oscillating ball (demo)

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%); } }

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 } }
Animated gif. Shows a white triangle oscillating left and right (while also rotating and being squished) and being XORed with the background that's half white (thus making the triangle black) and half black (leaving the triangle white).
Oscillating and rotating plastic shape (demo)

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).

Screenshot showing the Experimental Web Platform Features flag being enabled in Chrome.
The Experimental Web Platform features flag enabled in Chrome.
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:

4 squares distributed in a cross pattern turn out to be, two by two, the broken halves of two bars. They rotate back into position and stretch out vertically, then the bars rotate and get XORed to give us the initial cross pattern.
Crossing bars (demo)

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

Animated gif. Shows 12 moons in the last quarter phase distributed on a circle such that they overlap and XOR each other. In that position they then rotate around themselves with a delay depending on their index/ position on a circle, thus making the intersection/XOR pattern rotate as well.
Moons (demo)

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:

Animated gif. We start with four squares left in the corners of a square out of which we subtracted another inner square whose vertices are on the middle of the edges of the first (outer) square. This turns out two be the result of XOR-ing the inner square with the two triangular halves of the outer square. These triangular halves move out in the direction of their right angle corner, rotate by 45 and shrink until their catheti equal the small square edges and they don't intersect the inner square anymore and the middle of their hypotenuse is perpendicular onto a diagonal of the inner square. The inner square then splits in half along the other diagonal and the halves move out in the direction of their right angle corner until we get the initial shape again.
Discovery: two squares/ four triangles (demo, source)

Here’s another example of the same technique:

Animated gif. We start with the 8 triangles that result when we XOR two squares rotated at 45. Triangles 1, 2, 5, 6 move inwards forming two squares rotated at 45 which, when XORed, give us the initial shape again. The other triangles move out and disappear.
Eight triangles (demo, source)

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:

Animated gif. We start with 4 bars on the outside of a square. These bars move inwards until opposing ones touch. XORing them gives us the initial shape again.
Four bars (demo, source)

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):

Animated gif. We start with the 6 triangles we get when out of a 6 point star we subtract the hexagon formed by its 6 inner vertices. 2 opposing triangles out of these 6 grow and move inwards to intersect eventually giving us the same shape as we had initially, while the other 4 move out and shrink to nothing.
Six triangles (demo, source)

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.

Screenshot. Shows square boxes of text XORed with their offset frames that use the same `color` as the `background-color` of the box (either black or white).
Offset and XOR frame (demo).

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) */
We start with four dirty white squares on a purple background. These four squares are what's left in the corners of a square out of which we subtracted another inner square whose vertices are on the middle of the edges of the first (outer) square. This turns out two be the result of XOR-ing the inner square with the two triangular halves of the outer square. These triangular halves move out in the direction of their right angle corner, rotate by 45 and shrink until their catheti equal the small square edges and they don't intersect the inner square anymore and the middle of their hypotenuse is perpendicular onto a diagonal of the inner square. The inner square then splits in half along the other diagonal and the halves move out in the direction of their right angle corner until we get the initial shape again.
Discovery: two squares/four triangles — a more lively version (demo)

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));
}

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

Animated gif. Starts out with 9 pies all stacked one on top of the other and XORed (XORing an odd number of identical layers outputs a layer just like the input ones). They gradually slide out and get reduced to a slice that's one ninth of the pie, then slide back in order to together form a full pie again. Then it all repeats itself.
Radioactive 🥧 slices (demo)

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:

Screenshot. Shows square boxes of text XORed with their offset frames that use the same `color` as the `background-color` of the box (not necessarily black or white).
Offset and XOR frame — a more lively version (demo).

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

Screenshot. Shows square boxes of text XORed with their offset frames that use the same gradient as the background of the box.
Offset and XOR frame example — a gradient version (demo).

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

Screenshot. Shows square boxes of text XORed with their offset frames that use the same image as the background of the box.
Offset and XOR frame — an image version (demo).

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!