FLIP Technique for Layout Animations

Part of Compositor-Only Property Optimization in Performance Budgeting & GPU Architecture.

The problem

Cards reflow when a filter is applied: some disappear, the rest move into new grid positions. Animating that with transitions on top and left — or by animating grid-template-columns — runs layout on every frame for every card. With forty cards on a mid-range phone, the reflow animation costs more than the work it is decorating.

The elements need to appear to move from their old positions to their new ones, but the layout only needs to happen once.

Root cause analysis: separate the layout change from the motion

FLIP — First, Last, Invert, Play — is the technique that separates them.

First. Before the change, measure each element’s position and size with getBoundingClientRect().

Last. Apply the change and measure again. The new layout is final and correct; nothing has animated yet.

Invert. Compute the difference between old and new, and apply a transform that moves each element back to where it visually was. The DOM is in its new state, but the screen looks unchanged.

Play. Remove the transforms with a transition. The compositor animates each element from its inverted position to its natural one — with no layout per frame, because transforms do not affect layout.

Two rules make the difference between a smooth FLIP and a janky one.

Batch the reads and the writes. All getBoundingClientRect() calls for the First pass, then the DOM change, then all Last reads, then all writes. Interleaving reads and writes forces layout repeatedly, the layout thrashing pattern.

Scale distorts children. If elements also change size, the inverted transform includes scaleX/scaleY, which stretches text and borders during the animation. Either animate position only, or counter-scale the contents in a child element.

The four FLIP stepsLayout happens once, between First and Last; everything after is transform.The four FLIP stepsFirstmeasure oldChangeone layoutLastmeasure newInverttransform backPlaytransition to none
Layout happens once, between First and Last; everything after is transform.

Step-by-step resolution

Animating a filtered gridRead, change, read, write, play.Animating a filtered grid1Collect the cards that will remain and measure them.First rectangles2Apply the filter, adding and removing cards.One layout pass3Measure the same cards again in one batch.Last rectangles4Set translate on each card to the old-minus-new delta, with transitions off.Screen looks unchanged5On the next frame, enable transitions and clear the transforms.Cards glide to new positions6Under reduced motion, stop after applying the filter.
Read, change, read, write, play.

Production code pattern

const reduce = matchMedia('(prefers-reduced-motion: reduce)');

export function flip(elements, mutate, { duration = 300, easing = 'cubic-bezier(0.4, 0, 0.2, 1)' } = {}) {
  if (reduce.matches) return mutate();

  // FIRST: one batch of reads.
  const first = new Map();
  for (const el of elements) first.set(el, el.getBoundingClientRect());

  mutate();                                          // the layout change itself

  // LAST: one batch of reads, after layout has settled.
  const last = new Map();
  for (const el of elements) last.set(el, el.getBoundingClientRect());

  // INVERT: one batch of writes.
  const moved = [];
  for (const el of elements) {
    const a = first.get(el), b = last.get(el);
    if (!a || !b) continue;
    const dx = a.left - b.left;
    const dy = a.top - b.top;
    if (!dx && !dy) continue;                        // untouched cards skip the animation entirely
    el.style.transition = 'none';
    el.style.translate = `${dx}px ${dy}px`;
    moved.push(el);
  }

  // PLAY: next frame, transition back to the natural position.
  requestAnimationFrame(() => {
    for (const el of moved) {
      el.style.transition = `translate ${duration}ms ${easing}`;
      el.style.translate = '';
    }
  });

  for (const el of moved) {
    el.addEventListener('transitionend', function done(e) {
      if (e.propertyName !== 'translate') return;
      el.style.transition = '';
      el.removeEventListener('transitionend', done);
    });
  }
}

filterButton.addEventListener('click', () => {
  const cards = [...grid.querySelectorAll('.card')];
  flip(cards, () => applyFilter(grid));
});
.card {
  will-change: auto;                               /* promoted only while animating, by the browser */
}
.card--entering {
  animation: card-in var(--motion-enter-duration) var(--motion-enter-easing) backwards;
}
@keyframes card-in { from { opacity: 0; scale: 0.94; } }

@media (prefers-reduced-motion: reduce) {
  .card--entering { animation: none; }
}

Rendering Impact: one layout for the change, then composite per frame. Two forced layouts happen inside the helper — one implicit in the First reads and one after the mutation — which is the unavoidable cost of measuring.

Skipping elements whose position did not change is worth doing: in a filtered grid many cards stay put, and animating them costs layers for no visual benefit.

Main-thread time for a 40-card filter animationMid-range phone, 300 ms animation.Main-thread time for a 40-card filter animationTransition top and left268 ms totalAnimate grid-template-columns312 ms totalFLIP with translate34 ms totalView transition41 ms total
Mid-range phone, 300 ms animation.

When view transitions replace FLIP

A same-document view transition does the measuring and inverting internally: name the items, change the DOM inside startViewTransition, and the browser animates each group from its old box to its new one, as in animating list reorders with view transitions. It is less code and handles size changes without distorting text, because it animates snapshots rather than live elements.

FLIP remains the better tool in three cases: when the elements must stay live and interactive during the motion, such as while dragging; when the animation must be interruptible and retarget mid-flight; and when you need to support browsers without view transitions. Many teams now use view transitions as the default and keep a FLIP helper for drag-and-drop.

Verification checklist

Constraints and trade-offs

  • Two forced layouts per animation; batch them and avoid running FLIP on every keystroke.
  • Size changes need counter-scaling or a snapshot-based approach to avoid distorting content.
  • Inline styles from the helper can conflict with component styles that set the same properties.
  • Interrupting a FLIP animation mid-flight needs re-measuring from the current transformed position.
  • Very large numbers of animated elements promote many layers at once.

Frequently asked questions

What does FLIP stand for?

First, Last, Invert, Play: measure before the change, measure after, transform elements back to where they were, then transition the transform away.

Why is FLIP faster than animating top and left?

The layout change happens once. The visible motion is a transform, which the compositor animates without laying out anything.

How do I avoid stretched text when elements change size?

Animate position only, counter-scale the contents in a child element, or use a view transition, which animates snapshots instead of live elements.

Should I still use FLIP if view transitions are available?

Use view transitions for discrete changes, and keep FLIP for interactions where elements must stay live, such as dragging, or where interruption matters.