FLIP for smooth animations
Traditionally, animation in a browser has involved one of two strategies:
- Run the animation dynamically, updating position on each frame from JavaScript
- Define a CSS animation or transition, and let the browser handle it
Neither of these approaches is necessarily bad, but they each have serious disadvantages. Manually directing an animation from JavaScript means running code that touches the DOM on every frame, which is difficult to do smoothly (especially on resource-constrained devices). On the other hand, animating using CSS means that you're locking into specific changes that are defined ahead of time, and cannot easily respond to updated page content or user input.
In 2015, Chrome developer advocate Paul Lewis published an influential article called "Flip your animations" that proposed a third method: use JavaScript to define the start and end points of the animation, then let CSS take over so that the browser can smoothly transition between those two states. His technique wasn't without its own limitations, since it's optimized for changes in the transform property (namely position, scaling, and rotation). But since those are typically the properties we want to change anyway, it's a powerful tool to learn.
Breaking down the process
FLIP gets its name from the four stages of the animation process:
- First: Store the starting position of the element being animated
- Last: Move the element to its final position
- Invert: Using transforms, "move" the element back to its starting position
- Play: Finally, turn on transitions and remove the transforms to "play" the animation
It's probably easier to understand as code, though:
// get the initial position
var first = element.getBoundingClientRect();
// you can mutate this element's position or size
// you can even animate "into" a second element if you want
// here we'll add a class
element.classList.add("active");
// measure the resulting position
var last = element.getBoundingClientRect();
// invert the position
var t = {
top: first.top - last.top,
left: first.left - last.left,
width: last.width / first.width,
height: last.height / first.height
};
// this transform moves the element to its original position/size
element.style.transform = `translate(${t.left}px, ${t.top}px) scale(${t.width}, ${t.height})`;
// turn on transitions and remove the transform to "play" it
// we wait a frame to give the browser a chance to render
requestAnimationFrame(_ => {
element.classList.add("enable-transition");
element.style.transform = "";
});
The key to FLIP is that it prioritizes the CSS properties that browsers can animate smoothly, because they map cleanly to operations that can be offloaded to the GPU: fading opacity and transforming size or position without affecting layout. Other properties, like changing the color of a background or the layout dimensions of an element, require the browser to repaint element contents instead of re-using them in an composited layer. Paul Lewis' CSS Triggers can serve as a guide for which properties cause repaint, and which ones only need compositing.
Making FLIP slow
Here's the thing about doing FLIP: it's basically a process of moving the expensive parts of animation to the beginning of the process so that the rest can go smoothly. Those two calls to getBoundingClientRect() at the start of the process are relatively slow, but after that all animation is done by the browser without any script. For only one or two concurrent animations, that's fine.
However, if you're animating many elements—say, a scatterplot or a stacked bar chart that transitions between relative and absolute numbers—it's easy to blow through your frame budget by using FLIP naively. Each time you collect two measurements separated by a DOM mutation, it requires the browser to perform a full page layout and measurement, and you can't afford many of those.
Instead, if you have many elements you're planning on animating, you'll want to batch reads and writes. The browser is smart enough to cache the layout data in between getBoundingClientRect() calls as long as you don't change the page in any way, and it also applies changes lazily if you let it. So the key is to use an array to associate intermediate values across animation phases for many elements.
// start with a set of elements
var elements = $(".animation-targets");
// Perform "First" step for all elements
var flip = elements.map(function(element) {
return {
element,
first: element.getBoundingClientRect()
};
});
// Mutate all elements at once
flip.forEach(el => el.classList.add("active"));
// Get the "Last" position and invert
flip.forEach(function(f) {
var last = f.element.getBoundingClientRect();
var invert = {
top: f.first.top - last.top,
left: f.left.top - last.left
};
f.element.style.transform = `translate(${invert.left}px, ${invert.top}px)`;
});
// now after a frame to render, play all
requestAnimationFrame(function() {
flip.forEach(function(f) {
f.element.classList.add("enable-transitions");
f.element.style.transform = "";
});
});
This kind of batching is not new: Wilson Page's FastDOM library (created at the Financial Times) has been offering tools for years that let developers prevent costly document reflow by combining reads and writes. But with FLIP, we need to make it very explicit, and account for the anatomy of a frame. It may be possible, one day, that browsers will automatically make these kinds of performance improvements for us, but speaking from my limited experience with game engines, I doubt it.