Framer Motion's Layout Animations: When FLIP Saves You From Manual Transforms
Framer Motion's layout prop automates FLIP animations for position and size changes. This post explains how it works, shows a reorderable list example, and covers performance traps in React 18 concurrent mode.
02 Nov 2025, 13:47 UTC

The problem: layout shifts that feel broken
You've built a reorderable list. Drag an item, the others snap to new positions instantly. It works, but it feels jarring — users lose spatial context. You could write custom FLIP (First Last Invert Play) logic: measure before render, calculate deltas, apply inverse transforms, then animate to identity. But that's boilerplate you'll get wrong on the first try.
Framer Motion's layout prop handles this automatically. It measures the DOM before and after each render, computes the transform delta, and animates the inverse. No manual keyframes, no getBoundingClientRect calls in your components. This post covers how it works, where it shines, and the performance traps that bite teams in production.
How the layout prop actually works
When a motion component with layout re-renders at a new position or size — because a sibling was removed, a flex container reflowed, or a grid item changed tracks — Framer Motion runs a FLIP cycle:
- First: In a
useLayoutEffect(synchronous, before paint), it captures the element's bounding box. - Last: After React commits the new render, it captures the new bounding box.
- Invert: It calculates the transform (translate/scale) that would make the new box visually match the old one.
- Play: It applies that inverse transform via inline
style.transform, then animates it toidentityusing the Web Animations API (WAAPI) where available.
Because measurement happens in useLayoutEffect, the browser never paints the "jump" — the user sees only the smooth interpolation. The prop accepts a boolean or a string: "position", "size", or "position size" (default). Constraining to "position" skips scale calculations for width-only expansions, reducing transform complexity.
Cross-component morphing with layoutId
The layoutId prop extends FLIP across mount/unmount boundaries. Two different components sharing the same layoutId inside a single AnimatePresence will morph into each other: a thumbnail expands into a full-screen modal, a sidebar item becomes a detail panel. The outgoing component measures its final layout, the incoming component measures its initial layout, and Framer Motion animates the transform between them.
Critical constraints:
- Both components must be descendants of the same
AnimatePresence(or share aPresenceContext). - Portals and separate React roots break the shared context — you'd need manual coordination via
LayoutGroupor custom measurement. - On SSR (Next.js, Remix), the initial client-side measurement may differ from the server-rendered HTML, causing a flash. Framer Motion 11 defers
layoutIdactivation until hydration completes, but a mismatch still produces a one-frame jump.
Worked example: reorderable list without the boilerplate
Here's a minimal pattern for a drag-to-reorder list. The key is putting layout on each item, not the container.
// List.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
interface Item { id: string; label: string; }
export function ReorderableList({ initialItems }: { initialItems: Item[] }) {
const [items, setItems] = useState(initialItems);
const move = (from: number, to: number) => {
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setItems(next);
};
return (
<ul style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<AnimatePresence>
{items.map((item, index) => (
<motion.li
key={item.id}
layout
drag="y"
dragConstraints={{ top: -index * 48, bottom: (items.length - index - 1) * 48 }}
dragElastic={0.2}
onDragEnd={(_, info) => {
const target = Math.round(info.offset.y / 48);
const to = Math.max(0, Math.min(items.length - 1, index + target));
if (to !== index) move(index, to);
}}
style={{
padding: '12px 16px',
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: 8,
boxShadow: '0 1px 2px rgb(0 0 0 / 0.05)',
cursor: 'grab',
touchAction: 'none'
}}
>
{item.label}
</motion.li>
))}
</AnimatePresence>
</ul>
);
}
Each motion.li gets layout. When move() reorders the array, React re-renders the list. Framer Motion measures each item's old and new position, then animates the transform delta. The drag constraints keep movement within the list bounds; onDragEnd snaps to the nearest index. No manual transform math.
Performance trade-offs and React 18 gotchas
Every layout component calls getBoundingClientRect on each render. For a 100-item list, that's 100 layout reads per frame during reorder. In Chrome DevTools Performance tab, you'll see "Recalculate Style" and "Layout" spikes proportional to the animated count. Mitigations:
- Set
layout={false}on static items (headers, footers) — only animate what moves. - Virtualize long lists: only mount visible items, so
layoutmeasurements stay bounded. - Use
LayoutGroupon the container to batch measurements for nested layouts, but note that deep nesting (>3 levels) compounds transform matrices and can cause numerical drift.
React 18's concurrent mode adds a subtle hazard: a render phase may be started, measured, then discarded if a higher-priority update interrupts. Framer Motion 10+ synchronizes via useLayoutEffect, but edge cases persist with Suspense boundaries — a fallback resolving can leave residual transforms if the measurement occurred on a discarded tree. Enable React Strict Mode in development; it double-invokes effects and will surface double-fire bugs early.
Limitations you'll hit in real projects
- Non-geometric styles don't animate.
border-radius,box-shadow,background-color— useanimateorvariantsalongsidelayoutfor those. - External CSS transitions conflict. If your stylesheet has
transition: transformon the same element, Framer Motion's inline transforms fight the browser. Remove external transitions on layout-animated components. - TypeScript needs explicit types. Extend
HTMLMotionPropswithlayout?: boolean | LayoutType(importLayoutTypefromframer-motion) to avoid type errors on custom motion components. - Bundle size. Importing
{ motion, LayoutGroup }pulls the layout runtime. Tree-shaking works if you only importmotion, butlayoutIdandLayoutGroupadd ~3KB gzipped.
How to verify it's working in your build
- Open Chrome DevTools → Performance. Record a reorder interaction. Look for smooth 60fps frames (green bars) and minimal "Layout"/"Recalculate Style" chunks.
- Toggle
layoutoff on half the items; compare frame times. The difference quantifies measurement overhead. - Enable React 18 Strict Mode and wrap a list in
Suspense. Trigger a reorder while a sibling suspends. Verify no double animations or stuck transforms after the fallback resolves. - Check hydration: load the page with DevTools network throttling (Slow 3G). Watch for a one-frame layout jump on
layoutIdmorphs — if it appears, ensure initial server HTML matches the client's first render.
If those checks pass, you've got production-ready layout animations without the FLIP boilerplate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.