EmpireUI
Get Pro
← Blog8 min read#flip animation#react#list

FLIP Animation in React: Smooth List Reorders With No Libraries

Learn how to implement FLIP animations in React for smooth list reorders — no Framer Motion, no GSAP, just the Web Animations API and a custom hook.

developer coding animation logic on a laptop screen

What FLIP Actually Means (and Why It Works)

FLIP stands for First, Last, Invert, Play. It's a technique Paul Lewis coined around 2015 to make expensive layout changes feel instant. The browser calculates where elements *will* be — then plays the animation *backwards* so the user sees smooth motion forward. Sounds backwards because it is, literally. That's the point.

Here's the problem with naively animating list reorders: CSS transition can only animate from a computed style to another computed style *on the same property*. When you move an item from position 3 to position 1 in a React list, the DOM re-renders and the element teleports. There's no CSS transition involved — the layout change already happened. transition: all 0.3s does nothing here.

FLIP sidesteps this entirely. Before React re-renders, you snapshot every element's getBoundingClientRect(). After re-render, you compare the new positions, invert the delta as a CSS transform, then immediately animate the transform back to zero. The element *appears* to travel smoothly from old position to new, even though the DOM already updated.

In practice, this technique handles sorting, drag-and-drop, filtering, pagination — any scenario where list order changes. And because you're only animating transform (which runs on the GPU compositor thread), you get 60fps even on mid-range Android devices. No layout thrashing.

The Core Algorithm — Step by Step

Let's break down the four steps before touching any React code. First: record the getBoundingClientRect() of each item using a stable key as the map key. Store a Map<string, DOMRect>. Do this synchronously *before* any state update triggers a re-render.

Second step — Last — let React re-render normally. The DOM now reflects the new order. Third, loop through every item again, call getBoundingClientRect() again, and diff the top and left values against the snapshot. If an element moved 120px down, the delta is deltaY = -120. Fourth, apply that delta as an instant transform: translateY(-120px), then use element.animate() from the Web Animations API to animate to transform: none over, say, 350ms.

// The core FLIP logic — framework-agnostic
function playFlip(
  snapshots: Map<string, DOMRect>,
  container: HTMLElement,
  duration = 350
) {
  const items = container.querySelectorAll('[data-flip-key]');

  items.forEach((el) => {
    const key = (el as HTMLElement).dataset.flipKey!;
    const prev = snapshots.get(key);
    if (!prev) return;

    const next = el.getBoundingClientRect();
    const deltaX = prev.left - next.left;
    const deltaY = prev.top - next.top;

    if (deltaX === 0 && deltaY === 0) return;

    el.animate(
      [
        { transform: `translate(${deltaX}px, ${deltaY}px)` },
        { transform: 'none' },
      ],
      { duration, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', fill: 'both' }
    );
  });
}

Worth noting: the easing here — cubic-bezier(0.25, 0.46, 0.45, 0.94) — is basically ease-out. It front-loads the movement so items feel snappy at the start. A purely linear easing on list reorders looks mechanical and wrong. You can tweak it with Empire UI's gradient generator mindset: just adjust until it feels good.

A `useFlip` Hook for React

Now let's wrap the logic in something actually usable. The tricky part in React is timing — you need to snapshot *before* state updates and call playFlip *after* the DOM commits. useLayoutEffect runs synchronously after DOM mutations but before paint, which makes it perfect for the "Last" and "Invert" steps.

import { useLayoutEffect, useRef, useCallback } from 'react';

type FlipSnapshot = Map<string, DOMRect>;

export function useFlip(deps: React.DependencyList, duration = 350) {
  const containerRef = useRef<HTMLElement | null>(null);
  const snapshotRef = useRef<FlipSnapshot>(new Map());

  // Call this BEFORE the state update that triggers reorder
  const snapshot = useCallback(() => {
    const el = containerRef.current;
    if (!el) return;
    const map: FlipSnapshot = new Map();
    el.querySelectorAll<HTMLElement>('[data-flip-key]').forEach((child) => {
      map.set(child.dataset.flipKey!, child.getBoundingClientRect());
    });
    snapshotRef.current = map;
  }, []);

  useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el || snapshotRef.current.size === 0) return;

    el.querySelectorAll<HTMLElement>('[data-flip-key]').forEach((child) => {
      const key = child.dataset.flipKey!;
      const prev = snapshotRef.current.get(key);
      if (!prev) return;

      const next = child.getBoundingClientRect();
      const dx = prev.left - next.left;
      const dy = prev.top - next.top;
      if (dx === 0 && dy === 0) return;

      child.animate(
        [
          { transform: `translate(${dx}px, ${dy}px)` },
          { transform: 'none' },
        ],
        {
          duration,
          easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
          fill: 'both',
        }
      );
    });

    // Clear snapshot after playing
    snapshotRef.current = new Map();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return { containerRef, snapshot };
}

The deps array works just like useEffect — you pass the state that drives the reorder, and the layout effect fires whenever it changes. One more thing — you're responsible for calling snapshot() before you update that state. The hook can't do that for you because React batches state updates and there's no pre-render hook that gets the current DOM.

Quick aside: fill: 'both' on the animate() call matters. Without it, items will flash back to their inverted position for one frame after the animation completes. The both fill keeps the final keyframe applied until the animation is garbage-collected.

Wiring It Into a Sortable List Component

Here's a complete working example — a list you can sort by clicking sort buttons, with every reorder animated. No Framer Motion. No GSAP. No auto-animate. Just ~80 lines of vanilla React.

import { useState } from 'react';
import { useFlip } from './useFlip';

interface Item { id: string; label: string; priority: number; }

const initialItems: Item[] = [
  { id: 'a', label: 'Design tokens', priority: 2 },
  { id: 'b', label: 'Build components', priority: 3 },
  { id: 'c', label: 'Write tests', priority: 1 },
  { id: 'd', label: 'Ship it', priority: 4 },
];

export function SortableList() {
  const [items, setItems] = useState(initialItems);
  const { containerRef, snapshot } = useFlip([items]);

  const sortByPriority = () => {
    snapshot(); // capture BEFORE state update
    setItems((prev) => [...prev].sort((a, b) => a.priority - b.priority));
  };

  const shuffle = () => {
    snapshot();
    setItems((prev) => [...prev].sort(() => Math.random() - 0.5));
  };

  return (
    <div className="p-6 max-w-sm">
      <div className="flex gap-3 mb-4">
        <button onClick={sortByPriority}
          className="px-4 py-2 bg-violet-600 text-white rounded-lg text-sm">
          Sort by priority
        </button>
        <button onClick={shuffle}
          className="px-4 py-2 bg-zinc-700 text-white rounded-lg text-sm">
          Shuffle
        </button>
      </div>
      <ul
        ref={containerRef as React.RefObject<HTMLUListElement>}
        className="space-y-2"
      >
        {items.map((item) => (
          <li
            key={item.id}
            data-flip-key={item.id}
            className="p-3 bg-white border border-zinc-200 rounded-xl text-sm shadow-sm"
          >
            {item.label}
          </li>
        ))}
      </ul>
    </div>
  );
}

The key line is data-flip-key={item.id} on each list item. That's how the hook tracks identity across renders. Never use array index for data-flip-key — if you do, a reorder will map the wrong before/after rect and you'll get garbled animations. Always use a stable entity ID.

Honestly, the boilerplate here is about 30 lines once you internalize the pattern. Compare that to adding Framer Motion as a dependency (~40kb gzipped for just the core), configuring AnimatePresence, wrapping every item in <motion.li> — for a use case this focused, the native approach is clearly leaner.

If you're also building the visual style of these lists — cards with depth, glassmorphism surfaces, neobrutalism borders — check out the Empire UI component library. The visual layer and the animation layer are independent concerns, and FLIP handles motion while Empire handles aesthetics.

Handling Enter and Exit Animations Together

Reorder isn't the whole story. Real lists also add and remove items. Enter animations (new items fading/scaling in) are easy — just use CSS keyframes or animate() on mount. Exit animations are where it gets interesting, because you need to keep the element in the DOM until its exit animation finishes before React removes it.

The cleanest pattern without a library is a "pending removal" state. Instead of removing items directly, mark them as exiting: true, play an exit animation, and remove them after the animation duration. Here's the pattern:

type ItemState = Item & { exiting?: boolean };

const removeItem = (id: string) => {
  // Mark as exiting
  setItems((prev) =>
    prev.map((item) => item.id === id ? { ...item, exiting: true } : item)
  );
  // Remove after animation
  setTimeout(() => {
    snapshot(); // snapshot for remaining items shifting up
    setItems((prev) => prev.filter((item) => item.id !== id));
  }, 300); // match your CSS animation duration
};

Then in the JSX, items with exiting: true get a CSS class like animate-fade-out while the snapshot + FLIP handles the remaining items sliding to fill the gap. The 300ms here matches animate-fade-out's Tailwind keyframe duration. Tweak to taste.

Worth noting: if users can trigger rapid removals (like hammering a delete button), debounce or queue the operations. Multiple simultaneous exit timers targeting overlapping item sets will produce conflicting FLIP snapshots and visual glitches.

Performance Constraints and When FLIP Breaks

FLIP is fundamentally limited by how fast getBoundingClientRect() can run. Calling it on 500 items triggers 500 forced layout reflows — that's actually fine in a tight loop since the browser batches reads, but if you're interleaving reads and writes you'll pay the reflow cost for each. Read all rects in one pass, write (animate) in a second pass. The hook above already does this correctly.

Lists beyond roughly 200 animated items will start to struggle on low-end hardware. In those scenarios, only animate items visible in the viewport using an IntersectionObserver to filter the snapshot map. Items off-screen don't need FLIP — they're not visible anyway.

One more thing — FLIP breaks entirely if your items use position: fixed or if the container uses CSS transform itself (like a 3D card flip). getBoundingClientRect() returns viewport-relative coordinates, but transform on an ancestor creates a new stacking context that warps those coordinates. If you're building something like a 3D card effect, be aware this interaction exists and test carefully.

The Web Animations API has been universally supported since Chrome 84 / Firefox 75 / Safari 13.1 — all released back in 2020. You don't need a polyfill in 2026. If you're somehow still targeting IE11... well, that's a different conversation.

In practice, FLIP covers 90% of real list animation needs without any library overhead. The remaining 10% — spring physics, drag-and-drop with pointer tracking, complex stagger choreography — is when you'd reach for Framer Motion or GSAP. Know the threshold and pick the right tool.

FLIP vs Libraries: When to Use Each

Look, Framer Motion's <AnimatePresence> and layout prop are genuinely great. They handle edge cases you haven't thought of yet — nested transforms, shared layout transitions, interrupted animations, scale correction. If you're building something like a Kanban board with drag-and-drop and card animations, use the library.

But for a sortable list, a filtered search result, a reordering todo list? You're adding a 40kb dependency and a non-trivial API surface to solve a 70-line problem. The custom useFlip hook above is fully debuggable, has zero external dependencies, and you understand every line of it. That matters when something breaks at 2am.

The UI layer is a separate question from the animation layer. Whether you're building with glassmorphism components or more structured neobrutalism styles, FLIP doesn't care. It operates on raw DOM geometry. Pair it with whatever visual design system makes sense for your product — they're orthogonal concerns.

One edge case worth calling out: React 19's compiler (released early 2025) can sometimes reorder or optimize re-renders in ways that shift the timing window for useLayoutEffect. If you're on React 19+ and see the animation fire before positions stabilize, add a queueMicrotask(() => playFlip(...)) call inside the layout effect to push execution one microtask later. Fixes it every time.

FAQ

Does FLIP animation work with React 18 concurrent mode?

Yes, but you need useLayoutEffect not useEffect — concurrent mode can defer useEffect past the paint, which means the DOM has already displayed the new positions before you animate. useLayoutEffect fires synchronously before paint and gives you the correct timing window.

Can I use FLIP with React's key prop for enter/exit animations?

Not directly. When React unmounts a component via key change, it's gone from the DOM immediately — there's no timing hook to animate the exit. You need to manage a 'pending removal' state manually and delay the actual unmount until after your exit animation duration.

Why not just use `auto-animate` or Framer Motion layout animations?

Both are solid choices for complex needs. For a simple sortable list, the native FLIP approach is smaller, faster to debug, and teaches you what those libraries actually do under the hood. It's worth understanding before reaching for a dependency.

What's the right animation duration for list reorders?

Between 250ms and 400ms feels natural for most lists. Under 200ms and users miss the motion entirely; over 450ms and it feels sluggish. The 350ms default in the hook above works well, but short lists with small deltas benefit from 250ms.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Virtual List in React: Rendering 100,000 Rows Without Breaking the BrowserParallax Scroll Sections in React: Performance-First ApproachVirtual Scrolling in React: tanstack-virtual, Window Sizing, Dynamic HeightsLottie Animations in React: Setup, Optimisation and Pitfalls