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 = 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!
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: '';
}
}
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.
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).
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!
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);
}
}
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;
}
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>
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
.
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 ¯_(ツ)_/¯ */
}
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.
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;
}
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()
.
background
)
Invert just an area of an element (or a 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:
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
.
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; }
}
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;
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);
}
}
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);
}
}
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).
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.
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%; } }
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 } }
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) */
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).
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!
Great article, and AMAZING demos!
Really amazed by this article, great work