Under the Hood
Animation

FLIP: animating layout changes without paying for layout

You often want to animate a layout change — a card growing, a list item reordering, an element jumping to a new parent — and the trick that makes it cheap is to let layout happen instantly, once, and then fake the entire visual transition with a single compositor-friendly transform.

FLIP: animating layout changes without paying for layout

Everything so far in this module has quietly assumed the property you're animating is one the browser can cheaply reinterpret every frame — a transform, an opacity, a color. But a lot of the animations real interfaces actually want are layout changes: a card expands and pushes its siblings down, a list item moves from position 3 to position 1 when a filter changes, a thumbnail flies from a grid into a full-size detail view in a different part of the DOM. None of those are expressible as "interpolate one property smoothly" — they're changes to where things are, computed by layout, which the layout/paint/composite lesson already established is the expensive stage of the pipeline: whole-subtree, no shortcuts, and definitely not something you want re-running on every one of sixty frames a second.

So there's an apparent conflict. You want to animate a layout change. Animating layout properties every frame blows the budget from lesson 1. The resolution is a technique usually called FLIP — First, Last, Invert, Play — and once you see the mechanism, the trick is almost embarrassingly simple: you do the layout change exactly once, instantly, with no animation at all, and then you use a transform (composite-only, per lesson 4) to visually fake the entire transition from old position to new. The layout math never runs mid-animation. Only the first and last frames are ever really "laid out"; everything in between is the GPU repositioning a bitmap it already has.

The acronym, walked mechanically

F — First. Before anything changes, measure the element's current position and size with getBoundingClientRect(). This gives you a DOMRecttop, left, width, height — that describes exactly where the element is right now, in viewport coordinates.

const first = card.getBoundingClientRect();

L — Last. Now make the actual DOM/layout change you wanted — reorder the list, add the "expanded" class, move the element to its new parent, whatever triggers the new layout. Do this with no transition, no animation, nothing gradual. The browser recalculates layout for it in the ordinary course of the next frame, exactly the way it would for any instantaneous style change. Then measure again:

applyFinalState(); // e.g. card.classList.add('expanded')
const last = card.getBoundingClientRect();

At this exact moment, the element is already, physically, in its final layout position. There has been no animation yet — the jump from old layout to new layout happened in a single, ordinary, unanimated frame. This is the part that feels wrong the first time you do it: you're not easing into the new layout, you're snapping to it immediately and only then figuring out how to animate.

I — Invert. This is the actual trick. You know exactly how far the element moved (last minus first), so you can compute a transform that displaces the element by the negative of that delta — visually shoving it back to where it started, even though, as far as layout is concerned, it's already sitting at its final position:

const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
const deltaScaleX = first.width / last.width;
const deltaScaleY = first.height / last.height;

card.style.transformOrigin = 'top left';
card.style.transform = `
  translate(${deltaX}px, ${deltaY}px)
  scale(${deltaScaleX}, ${deltaScaleY})
`;
card.style.transition = 'none'; // apply the invert instantly, no animation yet

Because transform doesn't touch layout at all (this is exactly the composite-only property from lesson 4), applying this inverted transform doesn't undo the actual layout change — the element's real box, in the flow, is still at its new position and new size. It just makes the element look, pixel for pixel, like it never moved: the translate cancels out the position delta, and the scale cancels out the size delta. On screen, right now, nothing appears to have changed at all, even though structurally everything already has.

P — Play. Now, in the next frame, remove the inverted transform — transition it back to transform: none (or no transform):

requestAnimationFrame(() => {
  card.style.transition = 'transform 0.3s ease-out';
  card.style.transform = '';
});

Because that's a transform transitioning to its identity, the browser interpolates it exactly the way lesson 2 described — computing a blended matrix every frame — and because transform never invalidates layout or paint, every one of those interpolated frames is a pure composite-stage operation, run on the compositor thread from lesson 4, independent of whatever else the main thread is doing. The element visually glides from its old position to its new one over 300ms. But layout itself was computed exactly twice in the entire sequence — once for First, once for Last — and never again during the 300ms of visible motion.

Why this is cheap, stated in pipeline terms

Look at where each stage of layout/paint/composite actually gets paid for across the whole sequence. Layout runs exactly twice — once implicitly, when you force a read with the First measurement, and once when the Last state is applied and you measure again. Both of those are single, ordinary layout passes, no different in cost from any other synchronous DOM change; they are not stretched out or repeated across frames. Everything from Invert onward is transform only. Invert applies instantly (no transition yet, so there's exactly one extra paint of the element at its inverted transform, then nothing). Play is a transform transition, which — per lesson 4 — never triggers layout or paint again for the life of the animation; the compositor just re-renders the already-painted layer at a new position and scale, every frame, on its own thread.

Contrast that with the version most people write first: actually animating top/left/width/height (or the equivalent layout-affecting properties) directly, frame by frame, so the box eases from old geometry to new geometry. That version pays full layout, full paint, and composite on every single frame of the transition, because those properties are themselves inputs to geometry — there is no way to interpolate them without re-running layout to find out what the intermediate boxes actually look like. FLIP produces the identical visual result — the element eases from old rect to new rect — while paying layout's cost exactly twice, not sixty times a second.

Measure carefully: getBoundingClientRect forces layout

There's a sharp edge worth calling out explicitly, because it's exactly the kind of mistake the layout-thrashing lesson covers in depth: getBoundingClientRect() is a layout-triggering read. If there's a pending style change that hasn't been reflected in layout yet, calling it forces the browser to run layout synchronously, right then, on the main thread, out of its normal once-per-frame schedule, just to answer your question. That's exactly what you want for the Last measurement — you deliberately want layout to have settled before you read the new rect. But it means you have to be disciplined about when you read and when you write:

// Wrong: interleaving reads and writes across multiple elements
// forces a fresh synchronous layout for every single iteration
items.forEach((item) => {
  const rect = item.getBoundingClientRect(); // read
  item.style.transform = computeInvert(rect); // write
  // next iteration's read is now forced to recompute layout,
  // because the write just invalidated it
});
// Right: batch all reads first, then all writes
const rects = items.map((item) => item.getBoundingClientRect()); // all reads
items.forEach((item, i) => {
  item.style.transform = computeInvert(rects[i]); // all writes
});

FLIP is only cheap if you actually keep it to two layout passes. Measuring in a loop interleaved with writes turns "twice" back into "every iteration," which is the exact forced-synchronous-layout trap that lesson describes — FLIP doesn't grant you immunity from it, it just gives you a reason to be careful about batching your getBoundingClientRect() calls together, up front, before any writes happen.

The browser's own version of this: View Transitions

Because FLIP is such a common and mechanically well-defined pattern, browsers have started building a version of it in directly. The View Transitions APIdocument.startViewTransition(callback) — takes a snapshot of the current visual state, runs your callback to apply whatever DOM changes produce the new state (your "Last" step), takes a second snapshot, and then automatically cross-fades and transforms between the two snapshots for you, using the same compositor-only mechanics FLIP relies on by hand. It's a real, load-bearing convenience — it hands you the First/Last/Invert bookkeeping essentially for free, including for changes that cross document boundaries in the case of same-document and (increasingly) cross-document navigations — but it's still solving exactly the problem this lesson just walked through by hand: get a layout change to happen once, instantly, and represent the transition between old and new as cheap composite-stage work instead of repeated layout.

Where this goes next

You now have two distinct tools for keeping an animation off the expensive parts of the pipeline: the compositor thread (lesson 4) for properties that were never expensive to begin with, and FLIP (this lesson) for layout changes that look expensive but can be reduced to two layout passes plus a transform. What you don't yet have is a way to prove, on a real page, which of these techniques is actually working — whether your "cheap" transform animation is genuinely staying on the compositor, or whether something upstream is quietly forcing layout every frame anyway. That's the subject of the final lesson: Measuring animation: jank, dropped frames, and the tools.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why does FLIP apply the final DOM state (the 'Last' step) before any animation starts, instead of gradually transitioning toward it?
  2. What exactly does the 'Invert' step compute, and why does applying it not undo the layout change that already happened?
  3. How many times does layout actually run over the full course of a FLIP animation, and why is that number independent of the animation's duration?
  4. Explain, using lesson 4's pipeline terms, why the 'Play' step never triggers layout or paint on any frame.
  5. Why is getBoundingClientRect() dangerous to call inside a loop that also writes styles, and how does batching reads before writes fix it?
  6. What does document.startViewTransition() actually automate, and what problem is it still mechanically solving underneath?
  7. A teammate animates a list reorder by transitioning each item's top and left directly. Explain, in pipeline-cost terms, exactly what FLIP would save them and why.