Why your animations stutter
This week I added a template panel to Sorinai's meeting page: click a button and your template slides in beside your notes. I'd built the slide the quick way, to see whether the feature was worth having. It was definitely useful, but at the same time it also felt like the page was stuttering a lot every time it opened, so I measured what that slide was really costing.
What was the cost of the slide animation?
There are mainly 3 things a browser does to put a frame on the screen. Layout works out where every box goes and where every line of text wraps. Paint draws all of that into layers, which are really just pictures. Then the GPU slides or fades those finished pictures into place.
The first two jobs run on the main thread, the same one that runs your JavaScript (your logic) and answers your clicks. The third doesn't, which is why animating transform or opacity is so cheap: the pictures are already drawn, so they only need moving. Animating anything that changes the shape of the page, like a width or a margin, makes the main thread redo layout and paint on every single frame.
The quick version animated the notes' margin-left, which is the expensive kind. On every frame the notes got a tiny bit narrower, and the browser re-wrapped every line of them. Each slide kept the main thread busy for 85ms, in an animation that only lasts 220ms.
FLIP: do the layout once, animate a picture
The fix is a technique Paul Lewis named FLIP back in 2015, after its four steps. Here they are, using the panel:
- First: before anything changes, measure where the notes are.
- Last: open the panel for real and measure again. The notes have jumped straight to their new place, in a single layout.
- Invert: before the browser paints, move the notes back to where they were with a
transform. On screen, nothing seems to have happened. - Play: animate that
transformaway. The notes glide from where they were to where they already are.
The user sees a smooth slide. The browser did one layout instead of fourteen, and the GPU did all the moving.
If you've used the layout prop in Motion (formerly Framer Motion) or GSAP's Flip plugin, this is what they're doing under the hood.
The clever part is when the work happens. Lewis points out that after someone clicks, you have about 100ms before any delay is noticeable. Measuring is the expensive bit, because it makes the browser lay the page out, so FLIP does all of it inside that window. From then on the animation is only moving a picture, which the GPU can do at full frame rate.
In light of this, inside Sorinai, FLIP now lives in a small helper that every panel shares. Anything that should glide gets a data-glide attribute. Here's a simplified version of the core:
const origins = new WeakMap<Element, number>();
// First: call right before the change.
export function captureGlide(root: Element) {
for (const el of root.querySelectorAll("[data-glide]")) {
origins.set(el, centreX(el));
}
}
// Last, Invert, Play: call after the change, before the browser paints.
export function playGlide(root: Element) {
for (const el of root.querySelectorAll("[data-glide]")) {
const dx = origins.get(el)! - centreX(el);
el.animate(
[{ transform: `translateX(${dx}px)` }, { transform: "none" }],
{ duration: 220, easing: "cubic-bezier(0.2, 0.7, 0.3, 1)" },
);
}
}
In a component, captureGlide runs just before the state changes, and playGlide runs just after:
const togglePanel = () => {
captureGlide(pageRef.current);
setPanelOpen((open) => !open);
};
useLayoutEffect(() => playGlide(pageRef.current), [panelOpen]);
Some extra pointers:
Choose useLayoutEffect not useEffect. A layout effect runs after React has updated the page but before the browser paints. A normal effect can run after the paint, so if you opt for the latter, the result can be your component flashing to its final position for one frame before jumping back to start the slide.
Cancel a slide that's still running. If someone clicks twice quickly, the second measurement would catch the notes mid-glide. Cancelling first ensures you always measure where things really are.
Pick the right point to track. I usually measure the centre, which suits something centred in its space, like the notes. On some components, however, such as Sorinai's calendar and templates pages, which fill the whole space and whose width depends on an external state, I track the left edge instead. Tracking the centre there would open a gap along the page's left edge for a moment.
FLIP moves things, it doesn't resize them. You can fake a change of size with scale, but as Lewis warns, it distorts what's inside: text and rounded corners stretch with it. So in Sorinai the panels themselves take their new width instantly and reveal their contents, and only the content beside them glides.
With FLIP, the same slide dropped from 85ms of main-thread work to 29ms, and the gap grows the more notes you have:
However, it is worth noting that there are still limitations to FLIP. On the page with the most notes, the one layout the CPU still has to do was slow enough to make one of the frames late. Nothing can skip that, because the text really does end up narrower. FLIP just makes it happen once instead of on every frame.
Everywhere else
The same idea took care of the chat pane and the sidebar. The little audio meter, which JavaScript used to redraw on every frame, now works out its whole motion in one go and hands it to the GPU. The trickiest was the blur that new lines rise out of when you enhance your notes: a blur and a movement in the same animation force a redraw on every frame, until you tell the browser in advance with will-change.
Conclusion
Animate transform and opacity, and the GPU does the work. Animate a width or a margin, and the main thread re-reads your page on every frame. When the layout really has to change, change it once and FLIP the difference.
And measure before you rewrite anything. The numbers will tell you where the time is really going.
All of this is live now in Sorinai's latest update version 2.1.29. Open a meeting and slide the template panel in, and tell me how it feels! t.hung@sorinai.com