FLIP layout-animation techniques (v2)

Revision 2 of this 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; padding:20px;

  /* container-query setup */
  container-type:inline-size;
  contain:layout paint style;
  width:400px;
}

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

/* ——— 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

// ---------- one-time setup ----------
const CARD_COUNT = 400;
const stage = document.getElementById('stage');
const rectPool = new Array(CARD_COUNT); // object-pool
const io = new IntersectionObserver(() => {}, { root: stage });

// fill pool with empty rect-like objects
for (let i = 0; i < CARD_COUNT; i++) {
	rectPool[i] = {};
}

// helper – register every child exactly once
function registerCards() {
	[...stage.children].forEach((el) => io.observe(el));
}

// ---------- functions reused in multiple tests ----------
function makeGrid(reverse = false) {
	stage.innerHTML = '';
	const f = document.createDocumentFragment();
	for (let i = 0; i < CARD_COUNT; i++) {
		const d = document.createElement('div');
		d.className = 'card';
		d.dataset.id = reverse ? CARD_COUNT - i : i;
		f.appendChild(d);
	}
	stage.appendChild(f);
	registerCards(); // (re)attach IO observers
}

// Tick-bound memoized rect helper
let 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 = new WeakMap();
				purge = false;
			});
		}
	}
	return r;
}

// ------------ 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 getAllRectsViaIO() {
	// takeRecords() is cheap and returns *all* pending entries synchronously
	const entries = io.takeRecords();
	const len = entries.length;
	for (let i = 0; i < len; i++) {
		const r = entries[i].boundingClientRect; // already up-to-date
		// write into the pre-allocated pool object (no new allocations)
		const o = rectPool[i];
		o.left = r.left;
		o.top = r.top;
		o.width = r.width;
		o.height = r.height;
	}
	return rectPool;
}

function computeFrames(a, b) {
	const out = new Array(a.length);
	for (let i = 0; i < a.length; i++) {
		const ai = a[i],
			bi = b[i];
		out[i] = {
			dx: ai.left - bi.left,
			dy: ai.top - bi.top,
			sx: ai.width / bi.width,
			sy: ai.height / bi.height,
		};
	}
	return out;
}
function writeAllTransforms(frames) {
	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})`;
	}
}

// keep track of CQ state so we can skip work when nothing changes
let isWide = false;

/*****************  string-pool for transforms  ******************/
const txPool = Object.create(null); // key => string
function pooledTransform(dx, dy, sx, sy) {
	// dx|dy|sx|sy is < 12 chars, fast key
	const k = dx + '|' + dy + '|' + sx + '|' + sy;
	return (
		txPool[k] || (txPool[k] = `translate(${dx}px,${dy}px) scale(${sx},${sy})`)
	);
}

/*****************  IO with jumbo rootMargin  ******************/
const ioRM = new IntersectionObserver(
	() => {}, // we pull via takeRecords()
	{ root: stage, rootMargin: '100% 0px 100% 0px' },
);
function registerCardsRM() {
	[...stage.children].forEach((el) => ioRM.observe(el));
}
function getRectsViaIORM() {
	const entries = ioRM.takeRecords(); // all in one go
	for (let i = 0; i < entries.length; i++) {
		const r = entries[i].boundingClientRect;
		const o = rectPool[i]; // object-pool reuse
		o.left = r.left;
		o.top = r.top;
		o.width = r.width;
		o.height = r.height;
	}
	return rectPool;
}

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
Batched FLIP + pool + CQ guard + IO
/* ① read first positions */
const first = getAllRectsViaIO(); // single cached read

/* ② mutate DOM (order + maybe CQ break-point) */
makeGrid(true); // reorder like other tests
isWide = !isWide; // toggle width every run
stage.style.width = isWide ? "600px" : "400px"; // crosses 500 px CQ

// guard – if CQ break-point did NOT change we can early-out
const cqNowWide = stage.offsetWidth >= 500; // cheap style-only read
if (cqNowWide === isWide) {
  // break-point flipped
  /* ③ read last positions via IO  */
  const last = getAllRectsViaIO();
  /* ④ compute & write inversions */
  const frames = computeFrames(first, last);
  writeAllTransforms(frames); // single batched write
}

ready
Pure IntersectionObserver + Pool
// ① read first batch (already in object pool)
const first = getAllRectsViaIO();

// ② mutate DOM order *and* toggle CQ width
makeGrid(true);
isWide = !isWide;
stage.style.width = isWide ? '600px' : '400px';

// ③ read second batch
const last = getAllRectsViaIO();

// (finish – no FLIP, we just collected data)
ready
Batched FLIP + pooled transform strings
//   1 · first rects (IO, no reflow)
const first = getAllRectsViaIO();

//   2 · mutate DOM and (maybe) CQ flip
makeGrid(true);
isWide = !isWide;
stage.style.width = isWide ? "600px" : "400px";

//   3 · guard: skip if CQ state unchanged
const cqWideNow = stage.offsetWidth >= 500;
if (cqWideNow === isWide){

  //   4 · last rects
  const last   = getAllRectsViaIO();

  //   5 · compute & WRITE using pooled strings
  const frames = computeFrames(first,last);
  const els    = stage.children;

  for (let i=0;i<els.length;i++){
    const f = frames[i];
    els[i].style.transform = pooledTransform(f.dx,f.dy,f.sx,f.sy);
  }
}
ready
Transform-string pool + CSS Typed OM styleMap
/* 1 · first rects (tick-memo) */
const first = Array.from(stage.children, rectTB);

/* 2 · DOM reorder + width toggle (+ IO guard still applies) */
makeGrid(true);
isWide = !isWide;
stage.style.width = isWide ? "600px" : "400px";
const cqWideNow = stage.offsetWidth >= 500;
if (cqWideNow === isWide) {

  /* 3 · last rects (tick-memo again) */
  const last   = Array.from(stage.children, rectTB);

  /* 4 · compute frames */
  const frames = computeFrames(first, last);

  /* 5 · WRITE via attributeStyleMap.set() with a pooled CSSStyleValue */
  const els = stage.children;
  for (let i = 0; i < els.length; i++) {
    const f  = frames[i];
    const k  = f.dx + "|" + f.dy + "|" + f.sx + "|" + f.sy;

    // pool returns a *single* CSSStyleValue object per key
    let css = txPool[k];
    if (!css) {
      // build once, then cache
      css = new CSSTransformValue([
        new CSSTranslate(CSS.px(f.dx), CSS.px(f.dy)),
        new CSSScale(f.sx, f.sy),
      ]);
      txPool[k] = css;
    }
    els[i].attributeStyleMap.set("transform", css);
  }
}
ready
Skip IntersectionObserver during requestAnimationFrame drag
/***** drag flag *****/
let isDragging = false;
let lastRects  = null;          // cached across rAF’s within drag

/***** pointer handlers *****/
stage.addEventListener("pointerdown", () => {
  isDragging = true;
  lastRects  = null;            // force fresh read on the first tick
});
window.addEventListener("pointerup", () => {
  isDragging = false;
  lastRects  = null;
});

/***** rAF-loop benchmark (drag variant) *****/
function test13() {
  if (!isDragging) return;      // run only while dragging

  /* 1 · read first */
  const first = lastRects || getAllRectsViaIO(); // IO only once per drag

  /* 2 · mutate DOM (e.g. reorder based on pointer) */
  makeGrid(true);               // placeholder for your live reorder

  /* 3 · read last (forced) */
  const last  = getAllRectsViaIO();

  /* 4 · compute + write (string pool is fine here) */
  const frames = computeFrames(first, last);
  writeAllTransforms(frames);

  /* 5 · cache for next rAF */
  lastRects = last;
  requestAnimationFrame(test13);
}
requestAnimationFrame(test13);
ready

Revisions

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