Cross-fading any two DOM elements is currently impossible

Update: A spec change has landed to make this possible, it'll ship in Chrome 100, it's been implemented in Firefox, and it already existed as a non-standard feature in Safari. Soon this feature will be supported across all major browsers!

Ok, it isn't always impossible. Be amazed as I cross-fade the word "good" with the word "goat":

good goat

Cross-fading is easy when one of the elements is opaque and covers the other element. As you adjust the slider, the element displaying "goat" moves from opacity: 0 to opacity: 1. Job done!

But, what if neither element is fully opaque? Well, you can't use the same technique:

good goat

…as you can still see the first item underneath. In this situation, you've probably done the same as me, and faded the other item out at the same time:

good goat

And there's the problem. The "go" part of the element fades out a bit, then fades back in. If this was a proper cross-fade, the "go" part wouldn't change, since those pixels are the same in each element. Even with the letters that change, the cross-fade doesn't look right on the parts that intersect.

Why doesn't it work?

If you have a material that blocks out half the light behind it, you're left with 0.5 of that light. If you have two sheets of that material, you're blocking out half, then half again, and you're left with 0.25 light.

opacity: 0.5 works in the same way. If you put opacity: 0.5 on top of opacity: 0.5 you get the equivalent of opacity: 0.75, and that's why the "go" appears to fade out a bit during the cross-fade.

On the web, we layer things on top of other things all the time, but how it works is actually kinda complicated. The process is called compositing. Compositing takes each pixel of the source ('goat' in this case) and destination ('good' in this case) and combines them in some way, depending on the compositing operator.

For these operations, pixels have four values called channels: red, green, blue, and alpha (transparency). Channel values are generally in the range 0-1, where 0 means 'none' and 1 means 'full'.

The default compositing operator on the web and most applications is source-over. Here's a worked example of how source-over combines two pixels:

// Red 50% opacity
const source = [1, 0, 0, 0.5];
// Blue 50% opacity
const destination = [0, 0, 1, 0.5];

// source-over works with premultiplied colours,
// meaning their colour values are multiplied with their alpha:
function multiplyAlpha(pixel) {
  return pixel.map((channel, i) => {
    // Pass the alpha channel through unchanged
    if (i === 3) return channel;
    // Otherwise, multiply by alpha
    return channel * pixel[3];
  });
}

const premultipliedSource = multiplyAlpha(source);
const premultipliedDestination = multiplyAlpha(destination);

// Then, source-over creates a fraction of the destination,
// using the alpha of the source:
const transformedDestination = premultipliedDestination.map(
  (channel) => channel * (1 - premultipliedSource[3]),
);

// Then, the premultipliedSource is added:
const premultipliedResult = transformedDestination.map(
  (channel, i) => channel + premultipliedSource[i],
);

// Then, the alpha is unmultiplied:
function unmultiplyAlpha(pixel) {
  return pixel.map((channel, i) => {
    // Pass the alpha channel through unchanged
    if (i === 3) return channel;
    // Avoid divide-by-zero
    if (pixel[3] === 0) return channel;
    // Divide by alpha
    return channel / pixel[3];
  });
}

const result = unmultiplyAlpha(premultipliedResult);

Note: The code above is for educational purposes 🤪, it doesn't have the optimisations you'd need for production. There are libraries for that if you need them.

Anyway here's what happens to the values:

Red
Green
Blue
Alpha
From
To
Cross-fade
Destination
1.000
0.000
0.000
0.500
Source
0.000
0.000
1.000
0.500
Premultiplied destination
0.500
0.000
0.000
0.500
Premultiplied source
0.000
0.000
0.500
0.500
Transformed destination
0.250
0.000
0.000
0.250
Premultiplied result
0.250
0.000
0.500
0.750
Result
0.333
0.000
0.667
0.750
Destination
Source
Result

And there's the 0.750 alpha in the result, instead of what we want for a true cross-fade, 1.000. In fact, the colour is wrong too. If we're fading from red to blue, the mid point should be 50% red and 50% blue, but it's 33% red and 66% blue.

In terms of what we want for cross-fading, things seem to go wrong when we get to "transformed destination". That's where the values of the back layer are reduced, giving more weight to the top layer. A cross-fade isn't layered – the order shouldn't matter. In fact, if we missed out that step, and just added the premultiplied destination to the premultiplied source, we'd get the answer we want.

So what's the solution?

CSS has a function for this!

It's called cross-fade(), and here it is in action:

Perfect! Except it only works in Chromium and WebKit browsers and uses a -webkit- prefix. But more fundamentally, it only works with images, so it can't be used to cross-fade DOM elements. I had to use two SVG images to make the above demo work.

.whatever {
  background: -webkit-cross-fade(url(img1.svg), url(img2.svg), 50%);
}

It isn't really what we're looking for, but…

Plus-lighter

Chromium engineer Khushal Sagar went digging into the implementation of -webkit-cross-fade(), and it turns out it's implemented using a different compositing operator, plus-lighter.

It's basically the same as source-over, except it doesn't do that "transformed destination" step:

// Red 50% opacity
const source = [1, 0, 0, 0.5];
// Blue 50% opacity
const destination = [0, 0, 1, 0.5];

// plus-lighter also works with premultiplied colours:
const premultipliedSource = multiplyAlpha(source);
const premultipliedDestination = multiplyAlpha(destination);

const premultipliedResult = premultipliedDestination
  // But then the pixels are just added together:
  .map((channel, i) => channel + premultipliedSource[i])
  // Clamped to 0-1:
  .map((channel) => {
    if (channel < 0) return 0;
    if (channel > 1) return 1;
    return channel;
  });

// Then, the alpha is unmultiplied:
const result = unmultiplyAlpha(premultipliedResult);

And here we go:

Red
Green
Blue
Alpha
From
To
Cross-fade
Destination
1.000
0.000
0.000
0.500
Source
0.000
0.000
1.000
0.500
Premultiplied destination
0.500
0.000
0.000
0.500
Premultiplied source
0.000
0.000
0.500
0.500
Premultiplied result
0.500
0.000
0.500
1.000
Result
0.500
0.000
0.500
1.000
Destination
Source
Result

And that works! The red value goes from 1 to 0, the blue goes from 0 to 1, and the alpha stays full.

It's already in 2D canvas

Well, plus-lighter isn't, but lighter is, and it does the same thing but without the clamping.

const ctx = canvas.getContext('2d');
const mix = 0.5;
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1 - mix;
ctx.drawImage(from, 0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = mix;
ctx.drawImage(to, 0, 0, canvas.width, canvas.height);

And here it is in action:

We should be able to do this in CSS

One of the projects I'm involved in right now is shared element transitions, a feature to allow developers to create transitions between pages. Cross-fading DOM elements is a big part of that, so we need to solve the problem, but we'd like to solve it in a general way so it can be used by other features.

The bit we're missing is being able to use 'lighter' or 'plus-lighter' with any two DOM elements. Since we already have a way to change the blending of pixels in CSS, it could look something like this:

<div class="cross-fade-container">
  <div class="from"></div>
  <div class="to"></div>
</div>
<style>
  .cross-fade-container {
    display: grid;
    /* Make sure the different compositing is limited to this element */
    isolation: isolate;
  }
  .cross-fade-container > * {
    /* Layer the elements on top of each other */
    grid-area: 1 / 1;
    transition: opacity 1s ease-in-out;
  }
  .cross-fade-container .to {
    /* The new feature */
    mix-blend-mode: plus-lighter;
    opacity: 0;
  }
  /* Perform the cross-fade on hover */
  .cross-fade-container:hover .from {
    opacity: 0;
  }
  .cross-fade-container:hover .to {
    opacity: 1;
  }
</style>

Whether mix-blend-mode is the right place for this feature isn't 100% clear right now. If we need to keep the distinction between 'blending' and 'compositing', we might end up with something like mix-composite-mode instead.

Khushal has filed an issue with the CSS working group, so hopefully the ability to cross-fade two DOM elements won't be impossible for long!

Update: The proposal was accepted, and a spec change landed!

Update: It's landing in browsers!

As part of standardising this, folks from Safari mentioned that they already added plus-lighter (and plus-darker) to mix-blend-mode, so it was already supported as a non-standard feature in Safari.

It was implemented in Chrome, and it'll ship in Chrome 100. It's also been implemented in Firefox.

Here's a demo:

good goat

If your browser supports it, you should see a true cross-fade.

It's a little buggy in Safari, eg I see the tail of the 'd' hanging around even after fading to 'goat'. I've filed an issue.

Bonus round: Browser compositing is inaccurate

We've been talking about colours as 0-1, where 0.5 means "half of that colour", but that isn't how it works.

The human eye is much more sensitive to small changes in intensity at lower levels than higher levels, so values are transformed to take advantage of that. That transformation is called the electro-optical transfer function. However, the compositing maths is not designed for these non-linear values, so the result picks up an error.

Here's the 2D canvas example again, but this time the text is fading from red to green:

See how it seems to get darker at the 50% mark, as it reaches baby-poo green-brown? That's caused by these non-linear values.

Here's a WebGL version that manually converts the colours to linear values before doing the compositing:

This version doesn't pick up the error, so the brightness doesn't dip half way through.

It isn't just cross-fading where browsers pick up this error, it happens with gradients, resizing, blurring… all kinds of image processing.

Unfortunately, lots of code on the web depends on this wrongness, so we're unlikely to see a change here without some kind of opt-in. CSS Color Level 4 lays the groundwork for this by defining interpolation for various colour spaces.

There's an HTTP 203 episode on colour spaces if for some reason you want to hear more about colour spaces.

Oh, and please don't look at my WebGL code. I don't really know what I'm doing.

View this page on GitHub

Comments powered by Disqus

Jake Archibald next to a 90km sign

Hello, I’m Jake and that is my tired face. I’m a developer of sorts.

Elsewhere

Contact

Feel free to throw me an email, unless you're a recruiter, or someone trying to offer me 'sponsored content' for this site, in which case write your request on a piece of paper, and fling it out the window.