TL;DR: If you need a double rAF() for a transition to work, getComputedStyle().opacity may be a cheap, synchronous alternative.
Update July 7 2022: After publishing this article, I (re)watched the Web animation gotchas video where I first saw the
getComputedStyle().opacity
trick. It turns out I didn't just forget where I saw the trick, I also forgot Jake goes on to show how the Web Animation API (WAAPI) is a much better alternative (hey, I seem to remember others trying to point this out to me, too...).I still believe there are use-cases for CSS transitions and
getComputedStyle().opacity
. Besides, you could have pile of legacy code that uses transitions for animations and it isn't quite ready to be refactored to use CSS Animations or the WAAPI. Replacing "double rAF()"s withgetComputedStyle().opacity
might still be a nice little performance boost that also helps improve readability of the code. The post also goes into how the rendering pipeline relates to CSS transitions, how you can isolate Style and Layout calculations to specific elements, etcetera. So plenty of good stuff left.I'm not sure if I should just update this post or write a short new one, but I'll do either of those when I can find the time. Until then, I recommend watching the video linked above.
I thought I'd share a neat little trick I learned a few years back, just a nice and short blog post like I did a while ago. As it turned out, that little trick lives in the intricate world of the Browser rendering pipeline. I knew that, I just didn't plan on going into it all that deep. But it is important to know why this trick works and how to avoid the pitfalls. Spoiler: we're going to force the browser to do Style calculations in the middle of our code, which is usually considered a bad thing - and we're doing it on purpose! To help you understand why it's bad, why we're doing it anyway, and what not to do (and why not), I had to dive a little deeper than I anticipated.
Before we start, I need to get a few things out of the way:
A
.toast
may not be the best use-case for the trick I'm about to explain but I chose it because the code snippet is fairly short and easy to understand.If you're looking to do a
.toast
library yourself, there are far better examples out there.Before you build your entire animations system on top of transitions, you may want to look into alternatives like CSS Animations and/or the Web Animation API.
Okay, let's dive in.
A simple toast message
Consider the following example where we create a .toast
element, attach it to the DOM and then animate it into the viewport:
<html>
<body>
<!-- ... -->
<div class="toast-container"></div>
</body>
</html>
.toast-container {
contain: content;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.toast {
contain: content;
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background-color: #272727;
color: #fff;
transform: translate(0, 100%);
transition: transform 125ms ease-in-out;
}
.toast.show {
transform: translate(0, 0);
}
function createToast(message) {
// Create the element *with initial values*
// and add it to the DOM
const toast = document.createElement('div');
toast.classList.add('toast');
toast.textContent = message;
document.querySelector('.toast-container').append(toast);
// Wait for paint so the browser notices
// the element and its `transform` value
requestAnimationFrame( () => {
// Wait for the *next paint* so it notices
// the change of `transform`
requestAnimationFrame( () => {
toast.classList.add('show');
})
})
}
You may notice the nested requestAnimationFrame()
calls here. It's called a "double rAF()" and we need it for the transition to work. If we don't "double rAF()", the toast will just "pop" into the DOM at its final position and ignore the transition from translate(0, 100%)
to translate(0, 0)
.
As far as the browser is concerned, the initial value is translate(0, 0)
. I'll explain in more detail later on but first...
Before we continue
This article assumes you know a little about the pixel pipeline. In other words: How does the browser render your html, css and javascript, and how is it trying to optimize this.
The very short version is that the browser goes through 4 steps before it can ship a frame to your screen: Style > Layout > Paint > Composite. It doesn't need to run each of these steps for every frame, only when something changes. Even then, which steps need to run depends on what changed. Lastly, if any of these steps is triggered, it needs to run the following steps. For example, if Layout is triggered, the browser can use the existing Style but it has to run Paint and Composite. If it needs to Paint, it can skip Style and Layout, but it has to run Composite.
When you add an element to the DOM, the browser needs to (re)compute Style (and thus, run the entire pipeline) because it needs to figure out which styles apply to the element you just created. It knows you only added one element, but this usually does not mean the style calculation is isolated to that element itself. CSS selectors like :nth-child()
, +
(next sibling) and many others could now suddenly apply (or no longer apply) to elements in the DOM, so the browser needs to recalculate the entire thing.
But running the pipeline is (very) expensive, especially if you have lots of css and lots of elements.
As an optimization, browsers do not immediately run the render pipeline after every single line of code that adds an element or changes a css class - that would result in pretty bad performance if you add 100 elements in a loop. Style and Layout are probably not relevant right away; maybe you add more elements or change something that has an impact on other elements' styles. Heck, as far as the browser knows, you could just immediately remove that element! So instead, the browser will only run the pipeline when it's time to paint.
You can actually write code that will force the browser to run (parts) of the pipeline before paint. Usually, that's a bad thing. I'll get back to that in a second.
Why we need a double rAF()
A CSS transition works by interpolating between two values over time. For that, the browser needs to know the initial and the final value. The reason why we need a "double rAF()" is that the browser actually never computes the initial Style value before we change it - it only does that when it's time to paint, not in the middle of your code.
As far as the browser is concerned, the initial value is translate(0, 0)
and it has nothing to interpolate from.
By using the "double rAF()", we make sure that the browser adds the element to the DOM with the initial transform
value, we wait for paint so the browser actually computes and applies style values, and then we change it. We have to use a "double rAF()" because requestAnimationFrame()
fires just before Style and the rest of the render pipeline, so wrapping a rAF()
inside a rAF()
ensures that exactly one Style has occured before we run any more code.
Once the browser has painted (and thus applied the style to the element), we change the transform
value and the browser notices it, triggering the transition.
Any alternatives?
Doing a "double rAF" is fine I guess, but in this case there actually is an alternative that is fairly cheap and makes the code a lot easier to read.
Pretty much any read operation that depends on style will cause the browser to re-evaluate what it knows. For example, you may want to know an element's current height:
const { height } = element.getBoundingClientRect();
For this operation, the browser needs to know the current layout - but it (probably) already knows that because it had to calculate it to render the previous frame. So it can just return the value it already calculated.
Unless you just changed it because you, let's say, added an element!
function forceStyleAndLayout() {
// Create an element and append to DOM
const toast = document.createElement('div');
document.querySelector('.toast-container').append(toast);
// Force the browser to *immediately* run
// Style and Layout.
toast.getBoundingClientRect();
}
Now the browser can't just return a value it already knew because you're asking for a value that (may have) changed since the last paint. In this case, that element didn't even exist back then. The moment the browser hits the toast.getBoundingClientRect()
call, it will update what it needs to know before it can give you an answer. We're asking for a DomRect of a new element here (basically position and size), so the browser needs to recalculate Style and Layout.
Just what we need
Browsers are pretty good at knowing what they need to know in order to return you a value - if you help them. It can't risk giving you an incorrect answer, so it will always err on the side of caution and recalculate anything that might have been affected by changes we've made since the last paint.
You may have noticed that we're appending the .toast
element to a .toast-container
element. There's actually a good reason for using that container element and not just adding new elements to the body
: The body is the root of the DOM tree. Adding a single element there would mean its siblings and all their children need to go through both Style and Layout before the browser can be absolutely sure about where our new element goes and how it looks.
Yes, the browser would recalculate the entire DOM tree, and that would mean a lot of work in the middle of our code snippet.
We don't want to cause all this ruckus, so we put in a little bit of effort to isolate the work the browser has to do as much as possible:
- We have a separate container where we append the
.toast
element so we're not disturbing the entire DOM tree. - That container has
contain: content
set, which tells the browser we're absolutely sure that (almost) anything that happens in the container, stays in the container.
If you really want to isolate things, you should look into utilizing Web Components and Shadow DOM.
Still, appending the .toast
element and that getBoundingClientRect()
call will force the browser to do Style and Layout, right in the middle of our code. Pretty expensive. We're only interested in Style so the browser notices the change of the transform
value.
Meet getComputedStyle()
.
Instead of forcing Style and Layout, you can force the browser to just update its knowledge about the Style of the DOM:
function forceStyle() {
// Create an element and append to DOM
const toast = document.createElement('div');
document.querySelector('.toast-container').append(toast);
// Force the browser to *immediately* run Style
getComputedStyle(toast).opacity;
}
Note that we're reading the opacity
property here. We're using that specific property because it's independent of Layout. The browser knows that and will only calculate Style (and skip Layout, at least for now).
Many other properties will absolutely cause the browser to do Layout. For example,
getComputedStyle().height
will return the actual pixels, not just the value in CSS. To computeheight
, the browser would need to check if nothing influences the final value. Is it in a flexbox? Did you setheight: auto
or100%
? The work here can still be container'ed, but Layout it will.
Jake Archibald, who responded to my request (and found the time) to check this post before it went live, pointed out that it may be worth mentioning that, contrary to what the name may suggest,
getComputedStyle()
by itself does very little:The name & signature suggests that the calculation is performed when getComputedStyle is called, but it isn't. The values are 'live', as in they're calculated when you access the getters, which is why you have to access eg 'opacity'.
Jake Archibald
We also made sure we do our work in .toast-container
and both it and our .toast
have contain: content
so the browser knows it can ignore anything outside of the container. It only needs to do Style for that single element!
We do cause Layout when it's time to render the upcoming frame, but not in the middle of our code!
A simple(r) toast message
Putting all of this together, this is the updated code snippet:
function createToast(message) {
const toast = document.createElement('div');
toast.classList.add('toast');
toast.textContent = message;
document.querySelector('.toast-container').append(toast);
// Force the browser to *immediately* run Style
getComputedStyle(toast).opacity;
toast.classList.add('show');
}
Another (better) alternative?
Bruno Stasse and Alex Russell, who were kind enough to proof-read this post, pointed out that it could be worthwhile to entirely swap transitions for CSS Animations and the Web Animations API (WAAPI). To be honest, I don't have much experience with either of them, but regarding the example of a .toast
, they are (probably, I mean.. I don't know 🤷♂️) absolutely right.
[...] Both CSS Animations and WAAPI don't require to wait for the styles to be set to start, because they work with defined keyframes, which include the first one which matches the initial styles.
They're a bit more complicated to use however, and if all you use in your website/app is CSS transitions, you don't necessarily want to switch to another method just for such use case.
And if you build components for others, you might want to build that in so they can use simple transitions.
Bruno Stasse
So you could add the element and just start an animation, which would actually (...probably) save you the cost of the second* Style recomputation.
Let's just say it's worth checking if CSS Animations or WAAPI could save you from having to do transition
shenanigans.
* Oh. I forgot to mention that, didn't I? Adding an element to the DOM, forcing the browser to apply Style, then changing the style, actually causes two Style calculations. The first one we covered (and we made sure it has minimal impact, only causing Style and not Layout). But because we're adding the
.show
class, we're causing another Style computation. However we go about it, adding an element to the DOM that the browser knew nothing about and demanding a transition, will at the very least, cause two Style computations. The upside is that the "double rAF()"-method actually causes two full Style-and-Layouts, while this method, if done correctly, only causes two Styles and one Layout.
With great power...
You should not use this trick lightly. There are beers on the road 🍻.
It's fine if you do it for one element once in a while, but remember you're intentionally blocking the main thread and forcing a Style calculation in the middle of your code. If you need to add multiple elements to the DOM and have them transition, make sure you avoid thrashing the layout:
- Add all elements to the DOM first
- Then force the paint
- Then trigger the transition
As I pointed out, it's absolutely worthwhile - nay, necessary - to use CSS' contain
property to ensure you isolate the layout to the element(s) that benefit from it. In most cases, setting contain: content
should do the trick without risking too many side-effects.
Further reading
Watch Jake and Surma talk about Web animation gotchas, that's where Jake mentioned this trick (and then I forgot).
I highly recommend watching Jake Archibald's excellent (and entertaining) In The Loop talk if you want to know more about how the browser event loop works and where rAF()
fits into that picture.
Paul Lewis wrote about Rendering performance and Layout thrashing, which are also very much worth the read.
Paul Irish compiled a list of what forces layout / reflow.
Finally, if you're looking into doing your own .toast
library, it's absolutely worth checking Adam Argyle's "Building a toast component" post. He put way more thought into writing it than I did when I cooked up my little example code snippet.
Acknowledgements
- Jake Archibald, The Inventor of
getComputedStyle().opacity
. - Alex Russell, Bruno Stasse and Jake Archibald for proof-reading and feedback 💖.
- The Chrome DevRel team for their continuous effort to share their knowledge and insights.