Shared Layout Animations in Framer Motion: When layoutId Helps and When It Hurts
Expand a grid card into a detail view without a jarring cross-fade. Learn how Framer Motion's layoutId and AnimatePresence work, where distortion and performance costs appear, and how to verify the result in a real browser.
01 Apr 2026, 15:57 UTC

The cross-fade problem
When a thumbnail expands into a full detail view, a standard opacity cross-fade breaks the illusion of continuity. The user sees one image vanish and another appear. Framer Motion's shared layout animations close that gap: assign the same layoutId to the source and destination elements, and the library animates the bounding box between them instead of swapping unrelated nodes.
The trade is real, though. Shared layout animations are measurement-based, which means they read the DOM, compute deltas, and apply transforms. That buys smooth compositor-driven motion, but it introduces distortion, performance, and accessibility costs you need to plan for.
How the FLIP-style handoff works
Under the hood, layout and layoutId run a FLIP-style pass. FLIP stands for First, Last, Invert, Play. The library measures the element's box before the render, measures it again after, computes the delta, and applies inverse transforms for a single frame. Because the browser animates the transform back to zero on the compositor rather than reflowing layout every frame, the motion stays smooth even for large elements.
AnimatePresence is the companion piece. It defers unmounting exiting children until their exit prop finishes. That delay is what makes a layoutId handoff possible: the outgoing element stays in the tree long enough to be measured against the incoming one.
A grid-to-detail example
One practical stack for a gallery is layoutId on the image and title, with AnimatePresence mode="wait" around the conditional route swap. The example below keeps exactly one node per layoutId mounted at a time, which avoids the conflict that occurs when two elements claim the same identifier. Remember that AnimatePresence animates only direct children; an extra wrapper div or fragment silently disables exit animations.
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export function Gallery({ items }) {
const [selectedId, setSelectedId] = useState(null);
const selected = items.find((i) => i.id === selectedId);
return (
<AnimatePresence mode="wait">
{selected ? (
<motion.article
key="detail"
layoutId={`card-${selected.id}`}
exit={{ opacity: 0 }}
>
<motion.img
layoutId={`image-${selected.id}`}
src={selected.full}
alt={selected.title}
/>
<motion.h1 layoutId={`title-${selected.id}`}>{selected.title}</motion.h1>
<button onClick={() => setSelectedId(null)}>Close</button>
</motion.article>
) : (
<motion.ul key="grid" className="grid">
{items.map((item) => (
<motion.li
key={item.id}
layoutId={`card-${item.id}`}
onClick={() => setSelectedId(item.id)}
>
<motion.img
layoutId={`image-${item.id}`}
src={item.thumb}
alt={item.title}
/>
<motion.h3 layoutId={`title-${item.id}`}>{item.title}</motion.h3>
</motion.li>
))}
</motion.ul>
)}
</AnimatePresence>
);
}
If you want the leaving card pulled out of flow so siblings reflow immediately, popLayout is the alternative to mode="wait". It keeps the exiting node visually in place while removing it from document flow.
Distortion: the cost of scaling everything
The main limitation is distortion. Properties that are not transforms—such as border-radius, box-shadow, and text—get stretched during the size animation unless you correct them. A card that grows from 200 px to 800 px will smear its rounded corners and shadows across the transition.
You have three options. Animate borderRadius explicitly so it interpolates alongside the transform. Narrow the prop to layout="position" or layout="size" so only one dimension animates. Or accept the distortion for a fast transition where the smear is barely visible.
| Prop | What animates | Use when |
|---|---|---|
layout | Position and size | Whole-card expansion where distortion is acceptable or corrected |
layout="position" | Position only | Reordering list items without stretching contents |
layout="size" | Size only | Resizing a container while children stay fixed |
Measurement, performance, and what breaks
Because layout animations read the DOM, they are sensitive to how you size and position elements. Percentage sizing, flex gaps, position: fixed children, and long lists can produce jumpy or wrong results. Keep the animated subtree small, and never mix CSS transitions on width, height, or top with the layout prop—that produces double animations and visible jank. If you server-render, guard the initial render to avoid hydration mismatches.
Automated tests will not save you here. jsdom cannot measure layout, so unit tests will not exercise these animations. Validate in a real browser.
Accessibility and packaging checks
Exiting nodes remain mounted and focusable for the duration of the animation. If your detail view contains a form or a close button, keyboard focus can land on an element that is visually leaving. Manage focus yourself, and gate the effect with MotionConfig reducedMotion="user" or the useReducedMotion hook so users who prefer reduced motion get an instant state change instead of a transform.
Packaging is version-sensitive. The project now ships as motion (motion.dev) alongside the long-standing framer-motion package, with the same motion.div, AnimatePresence, and layoutId API. Confirm which specifier your lockfile resolves before publishing code.
# Run in your project root
npm ls framer-motion
# or, if your project uses the newer Motion package
npm ls motion
The expected check is that the resolved version printed by npm ls matches the specifier in your package.json. The risk is that a transitive dependency could pull in an unexpected major version with different behavior.
Verify before you ship
Start with a single layoutId on the image, reproduce the handoff in Chrome and Safari, and record a Performance trace. Confirm the animation stays on the compositor with no per-frame layout, including under CPU throttling. Then enable prefers-reduced-motion: reduce in devtools and confirm the reduced-motion path removes the transform. Finally, run the example under React StrictMode to check that AnimatePresence keys and layoutId uniqueness survive double-mounting.
If distortion appears, narrow the prop or animate borderRadius. If the transition feels heavy, shrink the animated subtree. Shared layout animations are powerful, but they are a deliberate trade of DOM simplicity for perceptual continuity.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.