open atlas
↑ Back to track
React, zero to senior RCT · 13 · 02

FLIP: layout animation without paying layout every frame

Animating top or height re-runs layout for every item on every frame. FLIP pays layout once: measure First, apply Last, Invert with transform, Play to identity. Batch reads before writes to avoid thrashing; Framer Motion layoutId is FLIP with automated measurement.

RCT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Design wanted the kanban cards to glide into place on reorder, and the first implementation did the obvious thing: a JS tween on every card’s top. With 200 cards the drag ran at 22 fps on the PM’s laptop — the trace showed Layout at 8 ms plus Paint at 4 ms repeating every frame, because every interpolated top value re-ran geometry for the whole column. The second attempt was prettier code with the same physics: CSS transitions on top. Same purple stripes, same 22 fps — the declarative form changed nothing about which pipeline stage the property dirties. The third attempt also shipped a bonus bug: a helper that “synced” positions by reading getBoundingClientRect after each style write in a loop, forcing 200 synchronous layouts in a single frame — a 380 ms freeze on every drop. The fix that finally held was a 30-line FLIP: measure every card before the reorder commits, let React commit the new layout in one shot, measure again, then animate each card from an inverted transform back to identity. Layout ran twice per drop instead of twice per frame; every animated frame was transform-only compositor work; the same laptop rendered the same 200 cards at a flat 60 fps. The lesson is not that layout animation is forbidden — it is that you may only pay for layout at the endpoints, never in the middle.

The problem: the destination is a layout change

Reordered lists, expanding cards, items entering a grid — the end state genuinely differs in geometry, so some layout work is unavoidable. The naive implementations animate the layout properties themselves (top, left, width, height), which means the previous lesson’s worst case multiplied by item count: every frame re-runs style, layout, and paint for every animated element and everything their geometry touches. Numbers from the Hook: layout on a 200-card column costs ~8 ms, paint ~4 ms — 12 ms of a 16.7 ms frame at 60 Hz gone before any JS, and arithmetically impossible at 120 Hz. The insight behind FLIP: browsers are excellent at transform animation, and any motion between two known rectangles can be expressed as a transform. So compute the two rectangles, and never animate geometry at all.

FLIP: First, Last, Invert, Play

First — measure every element’s rectangle before the change. Last — apply the real DOM change (React commits the reorder) and measure the new rectangles. Invert — for each element, compute the delta and apply a transform that visually moves it back to where it was: the DOM is in the final state, but the screen still shows the old one. Play — animate the transform to identity; the element glides to where it already is. In React, the seam for Last and Invert is useLayoutEffect: it runs after the DOM mutation but before paint, so the user never sees the un-inverted frame. Together these four steps mean layout runs at the endpoints only, never in the middle; skip the Invert step and the user sees a visual jump before the animation even starts.

function reorder(nextOrder) {
  // First: measure every row BEFORE React commits the new layout
  const first = new Map();
  refs.current.forEach((el, id) => first.set(id, el.getBoundingClientRect()));
  firstRects.current = first;
  setOrder(nextOrder);
}

useLayoutEffect(() => {
  const first = firstRects.current;
  if (!first) return;
  firstRects.current = null;
  // Last: new layout is in the DOM but not painted yet
  refs.current.forEach((el, id) => {
    const f = first.get(id);
    if (!f) return;                              // entered item: no First rect
    const l = el.getBoundingClientRect();        // batched reads — no writes yet
    const dx = f.left - l.left;
    const dy = f.top - l.top;
    if (dx === 0 && dy === 0) return;
    // Invert + Play: start displaced, animate to identity
    el.animate(
      [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }],
      { duration: 220, easing: "cubic-bezier(0.2, 0, 0, 1)" }
    );
  });
}, [order]);

Per drop this pays layout twice — once implicitly when First reads after the previous frame, once when the Last reads force layout for the freshly committed DOM — and then every animated frame is compositor-eligible transform work. WAAPI’s el.animate is the right Play tool: it composes with the element’s other styles, handles cleanup, and exposes finished and cancel for interruption.

getBoundingClientRect and layout thrashing

getBoundingClientRect is cheap when layout is clean — it reads cached geometry. It is expensive in exactly one situation: layout is dirty (something wrote a style or mutated the DOM since the last layout), in which case the browser must run a forced synchronous layout right now to answer correctly. Interleave reads and writes in a loop and you force one layout per iteration — the Hook’s 380 ms freeze was 200 forced layouts in one frame. The discipline is batching: all reads, then all writes. FLIP written correctly is naturally batched — First is a read pass, the commit is one write burst, Last is a read pass, Invert is a write pass. Break that order (read one card, transform it, read the next) and the transforms themselves are fine but the interleaving resurrects the thrash.

Quiz

A drop handler loops over 200 cards doing el.getBoundingClientRect() and then immediately el.style.top = ... for each card in turn. The drop freezes for ~400 ms. What is the mechanism?

Framer Motion layoutId: FLIP with the measuring automated

Framer Motion’s layout prop and layoutId are not a different technology — they are FLIP with the bookkeeping done for you. On every render the library measures the bounding boxes of layout-tracked elements, compares them to the previous measurements, and if a box moved or resized, applies the inverted delta as a transform and plays it to identity. layoutId extends the same trick across components: when a new element mounts with the layoutId of one that just unmounted, the library uses the old element’s rect as First and the new element’s rect as Last — a shared-element transition built from the same getBoundingClientRect plus transform you can hand-write. Knowing this demystifies the failure modes: layout animations cost a measurement pass per render (which is why you scope layout to what moves, not the whole tree), and anything FLIP cannot fake — text reflow, certain paint effects — Framer Motion cannot fake either.

Scale distortion and the counter-scale correction

Moving elements is the easy case — translate distorts nothing. Resizing via FLIP means animating scale, and scale multiplies everything inside: an expanding card animating from 320 px to 640 px wide starts at scaleX(0.5) un-inverted… inverted to appear original-size, but its children — text, avatars, borders — are visually squashed or stretched mid-flight. The correction is counter-scaling children: apply the inverse factor (1/parentScale, recomputed per frame because the parent’s scale changes per frame) to a child wrapper. Framer Motion does this automatically for nested layout elements; hand-rolled FLIP must drive both animations from one clock. Border-radius has the same disease — a 12 px radius under scaleX(2) renders as a 24 px-by-12 px ellipse — which is why production FLIP either animates radius separately or accepts the brief distortion on fast (under ~250 ms) animations.

When FLIP breaks

  • Text reflow. If the real width changes, text re-wraps at the destination width. FLIP shows scaled — stretched — text during flight, then text is suddenly correct at the end; there is no transform that re-wraps lines. Options: fix the text container’s width and animate only the box; crossfade between two snapshots; keep the animation short enough that stretching reads as motion blur.
  • Entering and exiting items. A list item that just mounted has no First rect (nothing to invert — give it a separate enter animation); an item that unmounted cannot be measured for Last (it is gone — exit animations need the element kept alive, which is what AnimatePresence does).
  • Stale measurement. First rects measured, then something else moves the page (an image loads, a banner collapses) before Last — the deltas are wrong and items glide from the wrong origin. Measure as late as possible, immediately before the commit.
Quiz

A teammate claims Framer Motion layoutId uses a native browser shared-element API, which is why it can animate an element between two components without per-frame JS cost. What does layoutId actually do?

The numbers: 200-item reorder, naive vs FLIP

Naive tween on top, 200 items, 60 Hz: every frame pays style recalc (~1.5 ms) + layout (~8 ms) + paint (~4 ms) ≈ 13.5 ms — over budget with zero JS, ~22 fps observed, and at 120 Hz not even theoretically possible. FLIP on the same list: the drop frame pays two layout passes (First reads ride the clean layout from the previous frame; Last reads force one layout ≈ 8 ms) plus 200 el.animate calls (~1 ms) — one long-ish frame of ~10–15 ms once, then every animated frame is transform-only: microseconds of main-thread cost, compositor does the rest, flat 60 fps on the Hook’s laptop. The trade is honest: FLIP buys per-frame freedom by paying a bounded, one-time measurement cost — and that cost scales with item count, which is why Framer Motion scopes measurement to layout-tagged elements rather than the whole tree.

Why this works

Why is transform cheap where width is expensive? Not magic — scope. Geometry is global: one width change can move siblings, stretch ancestors, re-wrap text three components away, so layout must consider the document, and the engine cannot bound the work. transform was defined to be local: it applies after layout, in the element’s own coordinate space, and is guaranteed not to affect any other element’s geometry — that guarantee is what lets the compositor apply it to a rasterized bitmap without asking the main thread. FLIP is the art of laundering a global, unbounded operation into a local, bounded one: do the global work (layout) exactly twice at the endpoints, express the difference locally (a transform per element), and let the cheap lane carry the motion.

Recall before you leave
  1. 01
    Walk through the four FLIP steps for a React list reorder, naming where each runs and why useLayoutEffect is the right seam.
  2. 02
    Explain layout thrashing, why FLIP done correctly avoids it, and the two visual artifacts of scaling via FLIP plus their corrections.
Recap

Layout animation is wanted exactly where it is most expensive: reorders, expansions, and shared elements end in genuinely different geometry, and animating the layout properties directly multiplies the previous lesson’s worst case by item count — the Hook’s 200 cards paid 8 ms of layout plus 4 ms of paint per frame and ran at 22 fps, identically for the JS tween and the prettier CSS transition, because the property class, not the syntax, decides the pipeline cost. FLIP escapes by paying layout only at the endpoints: measure First before the change, let React commit, measure Last in useLayoutEffect — after DOM mutation, before paint — then Invert each element by its delta and Play the transform to identity with el.animate, leaving every in-between frame as compositor-eligible transform work. The technique is inseparable from read/write discipline: getBoundingClientRect is microseconds on clean layout but forces a synchronous reflow when layout is dirty, so interleaving reads with writes resurrects per-iteration layout — batch all reads, then all writes, which FLIP’s own phases naturally enforce. Framer Motion’s layout and layoutId are this technique automated — measure on render, invert, play, including the cross-component shared-element case built from a mount and unmount sharing an id — which also explains their limits: measurement passes cost time proportional to what is tracked, and what transform cannot express, the library cannot either. Scale brings two honest artifacts — children distortion, corrected by per-frame counter-scaling, and border-radius stretching, corrected separately or tolerated briefly — and one hard wall: text reflow, where lines re-wrap only at the destination width and no transform fakes it. The arithmetic to remember: two layout passes per change versus one per frame — that ratio is the whole technique. Now when you see a list reorder or card expansion, you will reach for FLIP before reaching for a layout property — because you know exactly what those purple Layout blocks in the trace cost at 60 Hz, let alone 120.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.