Framer Motion Layout Animations: shared layout, AnimatePresence
Master Framer Motion layout animations in React — layoutId, AnimatePresence, and shared transitions that just work without manual FLIP math.
Why Layout Animation Is Harder Than It Looks
You know that feeling when you click a card in an app and it expands smoothly into a full-screen detail view, the content morphing in place rather than jumping? That's a layout animation, and doing it by hand is a pain. You'd need to record an element's bounding box before the state change, let React re-render, grab the new bounding box, then manually animate the *delta* between the two using CSS transforms. This technique has a name — FLIP (First Last Invert Play) — and implementing it yourself is about 80 lines of fiddly code you'll have to maintain forever.
Framer Motion handles all of that automatically. Add a layout prop to a motion element and it watches for any change to that element's size or position, runs the FLIP math internally, and plays a spring animation between the old and new layout. That's it. The first time you try it and it just *works*, you'll feel slightly cheated that you spent hours doing this manually before.
Worth noting: layout animations appeared in Framer Motion v3 (late 2020), but the API got a significant overhaul in v5 with LayoutGroup and in v6 with improved layoutId cross-component transitions. As of Framer Motion v11 (2024), the shared layout system is stable and the performance story is solid. If you're on an older version, upgrade — the breaking changes are minor and the gains are real.
This article covers the three concepts you actually need: the layout prop for single-element transitions, AnimatePresence for mount/unmount animations, and layoutId for the magic shared-layout trick where an element appears to move and morph between two completely different parts of your component tree.
The layout Prop: Automatic FLIP in One Word
Start simple. Any motion element with a layout prop will animate whenever its CSS layout changes — position, size, flexbox reordering, grid placement, all of it. You don't wire up any callbacks or store previous positions.
import { motion } from 'framer-motion';
import { useState } from 'react';
export function ExpandingCard() {
const [expanded, setExpanded] = useState(false);
return (
<motion.div
layout
onClick={() => setExpanded(!expanded)}
style={{
width: expanded ? 320 : 120,
height: expanded ? 200 : 80,
background: '#6366f1',
borderRadius: 16,
cursor: 'pointer',
}}
/>
);
}Click the div — it smoothly expands from 120 × 80 px to 320 × 200 px. The default spring feels natural but you can tune it. Pass transition directly on the element: <motion.div layout transition={{ type: 'spring', stiffness: 300, damping: 30 }}>. Honestly, the defaults are good enough for 90% of cases, but you'll want a snappier spring for list reordering and a slower one for card expansions.
One gotcha: layout animates the element's *box*, not its content. If you have text inside that card and it reflows during the animation, you'll get a squishing effect. The fix is to add layout to inner text containers too, or wrap them in a motion.div with layout="position" which animates position but not size changes.
Quick aside: layout works on absolutely positioned elements too, which makes it perfect for animating things like floating action buttons, sidebars that slide in, or tooltips repositioning as content shifts.
AnimatePresence: Making Unmounts Actually Animate
React doesn't have an unmount lifecycle hook that lets you delay removal from the DOM. When a component leaves the tree, it's gone immediately — no time for an exit animation. AnimatePresence solves this by wrapping your conditionally rendered content and holding it in the DOM until its exit animation completes.
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
export function Modal({ isOpen }: { isOpen: boolean }) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 flex items-center justify-center"
>
<div className="bg-white rounded-2xl p-8 shadow-2xl">
Modal content here
</div>
</motion.div>
)}
</AnimatePresence>
);
}The key prop is non-negotiable. AnimatePresence tracks children by key to know which ones are entering and which are leaving. Miss the key and you'll get bizarre double-renders or animations that fire at the wrong time. Always add an explicit, stable key to direct children of AnimatePresence.
For lists where items can be added or removed, wrap your entire list in AnimatePresence and give each item its own key. This gets you staggered enter/exit for free if you combine it with variants. The mode prop controls how transitions overlap: mode="wait" makes the outgoing element fully exit before the incoming one enters — great for page transitions. mode="popLayout" (added in v11) immediately removes the exiting element from layout flow so the surrounding content reflows instantly while the element itself still plays its exit animation.
In practice, mode="popLayout" is what you want for list item deletion — the list collapses immediately and the deleted item fades out on top, which feels much snappier than waiting for the exit animation before the list reflows.
layoutId: The Shared Layout Magic Trick
This is the feature that makes people think you've done something unreasonably clever. Give two different motion elements the same layoutId string, and when one unmounts and the other mounts, Framer Motion animates between their positions and sizes — even if they're in completely different parts of the component tree. It's the expand-from-thumbnail-to-hero pattern, and it takes about 10 lines to pull off.
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
const items = [
{ id: 'a', color: '#6366f1', label: 'Violet' },
{ id: 'b', color: '#ec4899', label: 'Pink' },
{ id: 'c', color: '#14b8a6', label: 'Teal' },
];
export function SharedLayoutDemo() {
const [selected, setSelected] = useState<string | null>(null);
return (
<>
<div style={{ display: 'flex', gap: 12 }}>
{items.map((item) => (
<motion.div
key={item.id}
layoutId={`card-${item.id}`}
onClick={() => setSelected(item.id)}
style={{
width: 80,
height: 80,
borderRadius: 12,
background: item.color,
cursor: 'pointer',
}}
/>
))}
</div>
<AnimatePresence>
{selected && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'fixed', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
}}
onClick={() => setSelected(null)}
>
<motion.div
layoutId={`card-${selected}`}
style={{
width: 300,
height: 400,
borderRadius: 24,
background: items.find(i => i.id === selected)?.color,
}}
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
}Look at that: same layoutId, two different render locations, and Framer Motion stitches them together with a smooth spring animation. No manual position tracking. No getBoundingClientRect. The library figures out that the small card in the grid and the large card in the overlay are logically the same element and animates between them.
For this to work, only one element with a given layoutId should be mounted at a time. If both are in the DOM simultaneously, Framer Motion doesn't know which is the "source" and which is the "destination" — you'll get weird duplicated elements. Always gate one of them behind a condition that's mutually exclusive with the other. The pattern above (thumbnail list + conditionally rendered full-screen overlay) is the canonical way to do it.
Want to add text or other content inside the expanded card that doesn't exist in the thumbnail? Add it with initial={{ opacity: 0 }} and animate={{ opacity: 1 }} with a short delay — this prevents content from appearing during the morph animation itself. The detail content fades in *after* the card has morphed into position. That small timing detail is what separates a polished shared layout from a janky one.
LayoutGroup and Coordinating Multiple Animated Elements
When you have multiple motion elements with layout that are siblings (like items in a list), Framer Motion needs to know they're related so it can coordinate their animations. That's what LayoutGroup is for — it wraps a group of layout-animated elements and lets them share a measurement context.
import { LayoutGroup, motion } from 'framer-motion';
export function SortableList({ items }: { items: string[] }) {
return (
<LayoutGroup>
{items.map((item) => (
<motion.div
key={item}
layout
transition={{ type: 'spring', stiffness: 350, damping: 35 }}
style={{
padding: '12px 16px',
marginBottom: 8,
background: '#f1f5f9',
borderRadius: 8,
}}
>
{item}
</motion.div>
))}
</LayoutGroup>
);
}Reorder the items array and each list item slides to its new position. Without LayoutGroup, each element animates its own layout change independently — usually fine, but when elements are moving into positions vacated by their siblings, timing mismatches cause elements to momentarily overlap. LayoutGroup batches the measurements and ensures every element's start position is recorded *before* any of them starts animating.
That said, you don't always need LayoutGroup. If your layout-animated elements don't overlap or interact visually during the transition, skip it. It's a performance cost — wrapping every list in a LayoutGroup on a complex page will trigger more layout reads than you need. Use it when you actually see the artifacts it fixes: elements snapping through each other or animating out of sync.
One more thing — LayoutGroup also scopes layoutId values. If you're rendering the same component multiple times on a page (say, three separate accordions), wrap each in its own LayoutGroup with a unique id prop. Otherwise all three accordions share the same layoutId namespace and you'll get cross-component shared transitions you definitely didn't intend.
Pairing Layout Animations With Your UI Design System
Layout animations don't live in a vacuum. They're attached to real UI components — cards, modals, tabs, lists — and the quality of those components determines whether the animation reads as intentional design or gratuitous flair. If you're building in a design style where motion is expected (aurora, glassmorphism, cyberpunk), a well-timed shared layout transition feels completely native. If you're doing neobrutalism, you'd probably want a snappier spring and skip the morph entirely in favor of an instant expand with a bold entrance animation.
Spring parameters matter a lot here. For glassmorphism cards — translucent, layered, soft — use stiffness: 200, damping: 25. The animation breathes. For neobrutalism elements with hard borders and flat fills, try stiffness: 500, damping: 40. Snappy, intentional, no bounciness. Match the physics to the aesthetic.
If you're building a gallery or portfolio where the shared layout expand-to-detail pattern really shines, the gradient generator and glassmorphism generator on Empire UI are useful for generating the background values you'll set as inline styles on those motion.div containers. Getting the background right matters — a layout animation morphing a card over a flat white background is boring. Morph it over a multi-stop gradient and suddenly it looks like a native app.
For production apps, combine AnimatePresence + layoutId with React Router's outlet pattern or Next.js App Router page transitions. Each route renders a motion.div with a shared layoutId anchored to the navigation item that was clicked. The clicked nav item morphs into the page container. It's a 2024–2026 pattern you're seeing in a lot of polished apps, and it's genuinely not complicated once you've got the mental model.
Common Pitfalls and How to Avoid Them
The number one issue people hit: layout animations breaking when position: absolute or position: fixed is involved. Framer Motion measures layout in the context of the document flow. Absolutely positioned elements have their own coordinate system. You'll see the animation start from (0,0) or jump to the wrong position. The fix is usually to add layout to the *parent* containing element as well, and make sure that parent has position: relative.
Second most common: forgetting that layout triggers on *every* re-render if the layout actually changed. If you're updating state frequently (typing in an input, polling an API), and a layout-animated element is in the same component, you'll get constant micro-animations that feel wrong. Memoize the layout-animated components or restructure state so the animated elements only re-render when visually necessary.
Third: layoutId transitions that look wrong because both elements briefly mount at the same time during a React Strict Mode double-invoke. This is a dev-only issue — in production it won't happen. But if you're seeing weird double-renders during development, that's why. You can also disable Strict Mode temporarily to confirm this is the cause before shipping.
In practice, the useReducedMotion hook from Framer Motion is worth adding to any layout animation that moves content more than ~50 px. When a user has prefers-reduced-motion set in their OS, you should skip the spatial animation entirely. It's one line: const shouldReduceMotion = useReducedMotion(); — then conditionally skip the layout prop or set transition={{ duration: 0 }}. Don't skip this; it's a real accessibility concern, not a nice-to-have.
FAQ
Not for layout prop animations on elements that stay mounted — those work standalone. You only need AnimatePresence when you want elements to animate as they unmount (leave the DOM), because React removes them immediately otherwise.
Yes, but both route pages need to be mounted simultaneously during the transition. In Next.js App Router, use the shared layout pattern with parallel routes or a persistent layout wrapper that keeps both pages in the tree during the navigation animation.
Usually a CSS transform or position mismatch — check that parent elements have stable positioning and aren't themselves changing layout simultaneously. Also verify you're not applying transforms via className that conflict with Framer Motion's transform-based animation.
Yes, that's one of the best use cases. Add layout to grid or flex children and re-sort the underlying array — each item smoothly slides to its new grid cell or flex position. Just wrap them in a LayoutGroup so measurements are batched correctly.