Shared Element Transitions in Framer Motion: When layoutId Earns Its Keep
Framer Motion's layoutId makes shared element transitions nearly free — one prop morphs a card into a detail view. Here's a working example, plus the duplicate-ID and performance traps to avoid.
13 Apr 2026, 08:46 UTC

You have a grid of product cards. Clicking one opens a detail view. The naive version — fade out the grid, fade in the detail page — feels like a page reload even when it isn't. What users actually expect now is the thumbnail to travel to its new position: the card's image becomes the hero image, the title slides into the header. In Framer Motion, that effect is one prop away: layoutId. This post covers how it works, a working example, and the two failure modes that bite people in production.
The thesis: let the library do the FLIP math
Animating an element from one DOM position to another is traditionally painful because the browser only knows the start and end states — you have to compute the delta yourself. The standard technique is called FLIP (First, Last, Invert, Play): record the element's bounding box before the change, record it after, apply an inverse transform so it appears unchanged, then animate that transform to zero.
Framer Motion's layout prop does this automatically for a single element whose layout changes (say, an item in a list that reorders). The layoutId prop goes further: it tells Framer Motion that two different component instances — possibly in different parts of the tree — are conceptually the same element. When one unmounts and the other mounts, the library measures both and animates the illusion of a single element morphing between them. Position, size, and border-radius are handled without you writing a single CSS transition.
A worked example: card to detail view
Assume React 18 and framer-motion v10 or later (the API below is stable across v10–v11, but pin your version and check the changelog if you're on something older). The pattern is a state toggle that renders either a small card or a large detail panel, with matching layoutId values on the pieces that should morph.
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export function ProductCard({ product }) {
const [open, setOpen] = useState(false);
return (
<>
{!open && (
<motion.div
layoutId={`card-${product.id}`}
onClick={() => setOpen(true)}
style={{ borderRadius: 12, overflow: "hidden" }}
>
<motion.img
layoutId={`img-${product.id}`}
src={product.thumb}
alt={product.name}
style={{ width: 160, height: 160, objectFit: "cover" }}
/>
<motion.h3 layoutId={`title-${product.id}`}>
{product.name}
</motion.h3>
</motion.div>
)}
<AnimatePresence>
{open && (
<motion.div
layoutId={`card-${product.id}`}
style={{ borderRadius: 24, position: "fixed", inset: "10%" }}
>
<motion.img
layoutId={`img-${product.id}`}
src={product.thumb}
alt={product.name}
style={{ width: "100%", height: 320, objectFit: "cover" }}
/>
<motion.h3 layoutId={`title-${product.id}`}>
{product.name}
</motion.h3>
<button onClick={() => setOpen(false)}>Close</button>
</motion.div>
)}
</AnimatePresence>
</>
);
}Three things to notice. First, the layoutId strings must be unique per element per render cycle — namespacing with product.id is not optional decoration. Second, both states render the same conceptual pieces (card container, image, title) with the same IDs; that's what the engine pairs up. Third, AnimatePresence wraps the detail view so the reverse transition (closing) animates instead of snapping away.
To check it works: click the card and confirm the image visibly travels rather than cross-fading. Then resize the viewport mid-interaction and repeat — the FLIP measurement happens at transition time, so it should still land correctly at different screen sizes.
What it costs you
Layout animations are not free, and the cost model is worth understanding before you sprinkle layout everywhere.
- Measure-and-write churn. Every layout animation forces the browser to measure bounding boxes and then apply transforms. One morphing card is fine. Applying
layoutto hundreds of deeply nested items that all shift at once (think: a masonry grid reflowing on filter) can produce visible jank on mid-range phones. Profile with your browser's performance panel — look for long "Layout" and "Paint" blocks during the transition. - Duplicate IDs. If two mounted elements share a
layoutIdin the same render cycle, the engine can't tell which is the source and which is the target. The symptom is an element jumping to or from an unexpected position. This commonly happens when a list item is both rendered in the list and in a pinned "selected" slot. The fix is conditional rendering — only one instance alive at a time — or distinct IDs. - Conflicting CSS. The engine works in measured pixel coordinates. Elements under
position: fixedcontainers with their own transforms, or elements you're also transforming via CSS classes, can produce drift between where the element is and where Framer Motion thinks it is. If a morph lands slightly off-target, audit ancestors for transforms before blaming the library.
When to reach for something simpler
If the two states live in the same component and only size or position changes — a sidebar collapsing, an accordion opening — you don't need layoutId at all. A single motion.div with the layout prop covers it, and plain animate covers anything that isn't a layout change. Reserve layoutId for genuine cross-component shared elements; it's the most powerful of the three and the easiest to misuse.
Try it this week
Pick one interaction in your app that currently hard-cuts between states — a list-to-detail navigation is the classic candidate. Add matching layoutId values to the shared image and title, wrap the conditional in AnimatePresence, and verify three things: the element travels instead of fading, no duplicate IDs exist during the transition (React DevTools helps here), and a performance recording shows no layout storms. Fifteen minutes of work, and the interface stops feeling like a stack of pages.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.