FLIP layout-animation techniques

Benchmark created on


Description

Measures total ms needed to:

① capture first state ② apply new grid layout ③ prepare/invert transforms, not the play phase (animating is compositor work).

Preparation HTML

<!-- libs -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/Flip.min.js"></script>

<style>
/* ——— base ——— */
body{
  margin:0;
  font-family:sans-serif;
  background:#111;
  color:#ccc;
}

.stage{
  display:grid;
  grid-template-columns:repeat(auto-fill,70px);
  gap:8px;                 /* modern alias for grid-gap */
  padding:20px;

  /* container-query setup */
  container-type:inline-size;
  width:400px;             /* will be toggled to 600 px in the test */
}

.card{
  width:70px;
  height:70px;
  background:#3a79ff;
  border-radius:6px;
}

/* ——— container query overrides ——— */
@container (min-width:500px){
  .card{
    width:120px;
    height:120px;
    background:#ff5d3a;    /* visual proof CQ fired */
  }
}
</style>

<div id="stage" class="stage"></div>

Setup

// ------------ constants --------------
const CARD_COUNT = 400;
const stage = document.getElementById('stage');

// ------------ helper: create grid ----
function makeGrid(reverse = false) {
	stage.innerHTML = '';
	const frag = document.createDocumentFragment();
	for (let i = 0; i < CARD_COUNT; i++) {
		const d = document.createElement('div');
		d.className = 'card';
		// reverse order so each run rearranges everything
		d.dataset.id = reverse ? CARD_COUNT - i : i;
		frag.appendChild(d);
	}
	stage.appendChild(frag);
}

// ------------ helpers shared by tests ----
const rectCache = new WeakMap(); // element -> DOMRect
const matCache = new WeakMap(); // element -> {dx,dy,sx,sy}

function readAllRects() {
	const els = stage.children;
	const out = new Array(els.length);
	for (let i = 0; i < els.length; i++) {
		out[i] = els[i].getBoundingClientRect();
	}
	return out;
}

function writeAllTransforms(frames) {
	// apply inverse transform
	const els = stage.children;
	for (let i = 0; i < els.length; i++) {
		const t = frames[i];
		els[i].style.transform =
			`translate(${t.dx}px,${t.dy}px) scale(${t.sx},${t.sy})`;
	}
}

function computeFrames(first, last) {
	const arr = new Array(first.length);
	for (let i = 0; i < first.length; i++) {
		const a = first[i],
			b = last[i];
		arr[i] = {
			dx: a.left - b.left,
			dy: a.top - b.top,
			sx: a.width / b.width,
			sy: a.height / b.height,
		};
	}
	return arr;
}

// start every test with the same initial DOM
makeGrid(false);

// Tick-bound memoized rect helper
const rectCacheTB = new WeakMap();
let purge = false;

function rectTB(el) {
  let r = rectCacheTB.get(el);
  if (!r) {
    r = el.getBoundingClientRect();
    rectCacheTB.set(el, r);
    if (!purge) {
      purge = true;
      queueMicrotask(() => { rectCacheTB.clear(); purge = false; });
    }
  }
  return r;
}

Test runner

Ready to run.

Testing in
TestOps/sec
Naïve FLIP (per-node read)
// prepare first
const first = [];
for (const n of stage.children) {
	first.push(n.getBoundingClientRect());
}

// mutate DOM (reverse order)
makeGrid(true);

// read last & compute inversions inside loop (= flush each time)
for (let i = 0; i < stage.children.length; i++) {
	const node = stage.children[i];
	const last = node.getBoundingClientRect(); // layout thrash
	const inv = {
		dx: first[i].left - last.left,
		dy: first[i].top - last.top,
		sx: first[i].width / last.width,
		sy: first[i].height / last.height,
	};
	node.style.transform = `translate(${inv.dx}px,${inv.dy}px) scale(${inv.sx},${inv.sy})`;
}
ready
Batched FLIP (single read, single write)
const first = readAllRects(); // single layout read

makeGrid(true);               // mutate

const last  = readAllRects();  // single layout read (forced reflow)
const frames= computeFrames(first,last);
writeAllTransforms(frames);    // all writes (no extra reflow)
ready
Cached FLIP (skip unchanged)
const els = stage.children;
const first = new Array(els.length);
for (let i = 0; i < els.length; i++) {
	first[i] = rectCache.get(els[i]) || els[i].getBoundingClientRect();
}

makeGrid(true);

const last = readAllRects();
const frames = new Array(els.length);

for (let i = 0; i < els.length; i++) {
	if (
		first[i].left === last[i].left &&
		first[i].top === last[i].top &&
		first[i].width === last[i].width &&
		first[i].height === last[i].height
	) {
		// unchanged – reuse matrix
		frames[i] = matCache.get(els[i]) || { dx: 0, dy: 0, sx: 1, sy: 1 };
	} else {
		const inv = {
			dx: first[i].left - last[i].left,
			dy: first[i].top - last[i].top,
			sx: first[i].width / last[i].width,
			sy: first[i].height / last[i].height,
		};
		frames[i] = inv;
		matCache.set(els[i], inv);
		rectCache.set(els[i], last[i]);
	}
}
writeAllTransforms(frames);
ready
GSAP Flip (v7)
const state = Flip.getState(".card");
makeGrid(true);
Flip.from(state, {duration:0});   // duration 0: measure only
ready
GSAP Flip (v7) + Container-Query
/* 1️⃣ capture first (cards in “narrow” CQ state) */
const state = Flip.getState(".card");

/* 2️⃣ trigger *both* a DOM re-order and a container query flip */
makeGrid(true);                        // same reorder as other tests
stage._wide = !stage._wide;            // toggle width each run
stage.style.width = stage._wide ? "600px" : "400px";   // crosses the 500 px break

/* 3️⃣ play FLIP prep only (duration 0 = measure cost) */
Flip.from(state,{ duration:0 });
ready
Batched FLIP with tick-bound memo
const first = Array.from(stage.children, rectTB);

makeGrid(true);                 // DOM change

const last  = Array.from(stage.children, rectTB);
const frames= computeFrames(first, last);
writeAllTransforms(frames);     // animation prep
ready

Revisions

You can edit these tests or add more tests to this page by appending /edit to the URL.