# Use FLIP for Layout-Like Motion

**SHOULD** · **ID:** `animations-flip-technique` · **Category:** animations
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-flip-technique

> SHOULD: When a layout change genuinely must animate (list reorder, card expanding into a detail view), use FLIP — First, Last, Invert, Play: read the start rect, apply the final layout, read the end rect, apply the inverse `transform`, then release it in one rAF and let the compositor interpolate back to zero. Layout runs twice instead of 60x/sec. Measure ONCE and batch all DOM reads before writes — a `getBoundingClientRect()` inside the loop forces a synchronous layout every tick. (Does not contradict `layout-flex-over-measurement`: that bans JS measurement to BUILD a static layout; FLIP is the sanctioned way to ANIMATE a layout change.)

When a layout change genuinely must animate, measure once and play it back as a transform instead of animating geometry

Sometimes the thing that changes really is layout: a list reorders, a card expands into a detail view. FLIP — First, Last, Invert, Play — lets you animate that without ever animating a layout property. Read the start rect, apply the final layout in one go, read the end rect, apply the inverse transform so the element still *looks* like it is at the start, then release it in a single rAF and let the compositor interpolate the transform back to zero. The result is pixel-identical to animating `top`/`left`/`width`, but the browser does layout exactly twice instead of sixty times a second. The two supporting rules are the ones people break: "measure once, then animate via transform or opacity" and "batch all DOM reads before writes" — a `getBoundingClientRect()` inside the animation loop forces a synchronous layout on every tick, which is layout thrashing with extra steps. Note this does not contradict layout-flex-over-measurement: that rule says do not use JS measurement to *build* a static layout. FLIP is the sanctioned technique for the case where a layout change must be *animated*.

## Bad — do not do this

`animations-flip-technique-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

const ITEMS = ['Alpha', 'Beta', 'Gamma', 'Delta'];

/**
 * BAD: animating the reorder by writing `top` every frame, with a
 * getBoundingClientRect() read inside the rAF loop.
 * Read, write, read, write — each read after a write forces a synchronous layout,
 * so the browser recomputes geometry 60 times a second. That is layout thrashing.
 */
export function FlipTechniqueBad() {
  const [order, setOrder] = useState(ITEMS);
  const nodes = useRef<Record<string, HTMLLIElement | null>>({});
  const animating = useRef(false);

  useEffect(() => {
    if (!animating.current) return;
    animating.current = false;

    const start = performance.now();
    const step = () => {
      const elapsed = (performance.now() - start) / 320;
      for (const name of ITEMS) {
        const node = nodes.current[name];
        if (!node) continue;
        // READ layout...
        const rect = node.getBoundingClientRect();
        const drift = (1 - Math.min(1, elapsed)) * (rect.height + 8);
        // ...then WRITE a layout property. Every frame. Forced reflow, every frame.
        node.style.position = 'relative';
        node.style.top = `${drift * (ITEMS.indexOf(name) % 2 === 0 ? -1 : 1)}px`;
      }
      if (elapsed < 1) requestAnimationFrame(step);
    };
    requestAnimationFrame(step);
  }, [order]);

  const shuffle = () => {
    animating.current = true;
    setOrder((prev) => [...prev].reverse());
  };

  return (
    <div className="space-y-3">
      <button
        type="button"
        onClick={shuffle}
        className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Reorder
      </button>

      <ul className="space-y-2">
        {order.map((name) => (
          <li
            key={name}
            ref={(node) => {
              nodes.current[name] = node;
            }}
            className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground"
          >
            {name}
          </li>
        ))}
      </ul>

      <p className="text-xs text-destructive">
        `top` is a layout property and it is being written from a loop that also reads
        `getBoundingClientRect()`. The browser is forced to re-run layout on every frame — the reorder
        stutters, and it gets worse with every item you add.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-flip-technique-good`

```tsx
import { useLayoutEffect, useRef, useState } from 'react';

const ITEMS = ['Alpha', 'Beta', 'Gamma', 'Delta'];

/**
 * GOOD: FLIP — First, Last, Invert, Play.
 * Measure once before the change and once after, apply the inverse transform so the
 * element still LOOKS unmoved, then release it in a single rAF. Layout runs twice.
 * Everything the user sees is a compositor-driven transform.
 */
export function FlipTechniqueGood() {
  const [order, setOrder] = useState(ITEMS);
  const nodes = useRef<Record<string, HTMLLIElement | null>>({});
  const firstRects = useRef<Record<string, number>>({});

  useLayoutEffect(() => {
    const pending = firstRects.current;
    if (Object.keys(pending).length === 0) return;
    firstRects.current = {};

    // LAST: one read per element, all reads batched before any write.
    const lastTops: Record<string, number> = {};
    for (const name of ITEMS) {
      const node = nodes.current[name];
      if (node) lastTops[name] = node.getBoundingClientRect().top;
    }

    // INVERT: writes only, no reads mixed in.
    for (const name of ITEMS) {
      const node = nodes.current[name];
      if (!node || pending[name] === undefined) continue;
      node.style.transition = 'none';
      node.style.transform = `translateY(${pending[name] - lastTops[name]}px)`;
    }

    // PLAY: hand it to the compositor and let transform interpolate back to zero.
    requestAnimationFrame(() => {
      for (const name of ITEMS) {
        const node = nodes.current[name];
        if (!node) continue;
        node.style.transition = 'transform 320ms cubic-bezier(0.2, 0, 0, 1)';
        node.style.transform = '';
      }
    });
  }, [order]);

  const shuffle = () => {
    // FIRST: measure once, before the DOM changes.
    const tops: Record<string, number> = {};
    for (const name of ITEMS) {
      const node = nodes.current[name];
      if (node) tops[name] = node.getBoundingClientRect().top;
    }
    firstRects.current = tops;
    setOrder((prev) => [...prev].reverse());
  };

  return (
    <div className="space-y-3">
      <button
        type="button"
        onClick={shuffle}
        className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Reorder
      </button>

      <ul className="space-y-2 motion-reduce:transition-none">
        {order.map((name) => (
          <li
            key={name}
            ref={(node) => {
              nodes.current[name] = node;
            }}
            className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground"
          >
            {name}
          </li>
        ))}
      </ul>

      <p className="text-xs text-success">
        Two measurements total, all reads before any writes, and the motion itself is pure `transform` —
        the browser never lays out mid-animation. Same visual result, none of the thrash.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [Paul Lewis — FLIP Your Animations](https://aerotwist.com/blog/flip-your-animations/)
