Framer Motion layoutId Shared-Element Technique
The layoutId technique in Framer Motion allows a visual element in one component to morph continuously into the same element in another component, creating seamless shared-element transitions. When a user taps a photo thumbnail and it expands into a full-screen modal, the image doesn't teleport—it slides and scales from its original position to the new one, bridging spatial continuity across different views.
A layoutId is a string identifier that marks two elements as "the same" element across different render states. When both elements are in the DOM simultaneously (e.g., during an animated route transition), Framer Motion measures both, calculates the visual delta, and animates from one to the other. This is the technique behind the slickest mobile app interactions.
How layoutId Works
When two motion components share the same layoutId, Framer Motion treats them as a continuous element. The rendering process is:
- Component A renders with
layoutId="photo-1". - Animation starts; Framer Motion measures A's bounding box.
- Component B (different view) renders with the same
layoutId="photo-1". - Motion measures B's bounding box and animates A → B over 300 ms (default).
- At animation end, component A unmounts; B remains visible.
The magic is that A and B can be in different parts of the DOM tree, different routes, or different layouts. As long as they share a layoutId, Motion links them visually.
Basic Photo Expansion Example
Here is a thumbnail list where clicking a thumbnail expands it into a full-screen modal. Both use the same layoutId:
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
export default function PhotoGallery() {
const photos = [
{ id: 1, src: '/photo1.jpg', title: 'Beach' },
{ id: 2, src: '/photo2.jpg', title: 'Mountain' },
{ id: 3, src: '/photo3.jpg', title: 'Forest' },
];
const [selectedId, setSelectedId] = useState(null);
return (
<div style={{ padding: '20px' }}>
{/* Thumbnail grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '10px' }}>
{photos.map((photo) => (
<motion.img
key={photo.id}
src={photo.src}
layoutId={`photo-${photo.id}`}
onClick={() => setSelectedId(photo.id)}
style={{
width: '100%',
height: '120px',
objectFit: 'cover',
borderRadius: '8px',
cursor: 'pointer',
}}
/>
))}
</div>
{/* Full-screen modal */}
<AnimatePresence>
{selectedId && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedId(null)}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
}}
>
<motion.img
layoutId={`photo-${selectedId}`}
src={photos.find(p => p.id === selectedId)?.src}
onClick={(e) => e.stopPropagation()}
style={{
width: '90vw',
height: '90vh',
objectFit: 'contain',
borderRadius: '8px',
}}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
When you click a thumbnail, the modal fades in and the image animates from the thumbnail's position to the center of the screen. The shared layoutId makes this seamless. Click the modal background to close and reverse the animation.
Shared-Element Card to Detail Page
A common pattern: clicking a card on a list page animates it into a detail page. The card header becomes the detail page header:
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
const cards = [
{ id: 1, title: 'React Patterns', color: '#3b82f6' },
{ id: 2, title: 'TypeScript Tips', color: '#8b5cf6' },
{ id: 3, title: 'CSS Grid', color: '#ec4899' },
];
function CardList({ selectedId, onSelect }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', padding: '20px' }}>
{cards.map((card) => (
<motion.div
key={card.id}
layoutId={`card-${card.id}`}
onClick={() => onSelect(card.id)}
style={{
padding: '20px',
backgroundColor: card.color,
color: 'white',
borderRadius: '8px',
cursor: 'pointer',
minHeight: selectedId === card.id ? '300px' : '80px',
}}
>
<motion.h3 layoutId={`title-${card.id}`}>{card.title}</motion.h3>
</motion.div>
))}
</div>
);
}
function DetailPage({ card, onClose }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
}}
>
<motion.div
layoutId={`card-${card.id}`}
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: card.color,
color: 'white',
padding: '30px',
borderRadius: '8px',
width: '80vw',
maxWidth: '500px',
}}
>
<motion.h2 layoutId={`title-${card.id}`}>{card.title}</motion.h2>
<p style={{ marginTop: '20px' }}>
Detailed content about {card.title}. Multiple elements can share layout animation.
</p>
</motion.div>
</motion.div>
);
}
export default function CardDetail() {
const [selectedId, setSelectedId] = useState(null);
return (
<>
<CardList selectedId={selectedId} onSelect={setSelectedId} />
<AnimatePresence>
{selectedId && (
<DetailPage
card={cards.find(c => c.id === selectedId)}
onClose={() => setSelectedId(null)}
/>
)}
</AnimatePresence>
</>
);
}
Here, clicking a card animates it to the center. Both the card container and the title have layoutId props, so multiple elements animate in concert. The background fades in separately. Close the modal to reverse everything.
layoutId with Route Transitions
For React Router, pair layoutId with AnimatePresence to animate shared elements across page navigation:
import { motion, AnimatePresence } from 'framer-motion';
import { useParams } from 'react-router-dom';
// List view
export function ProductList({ onSelect }) {
const products = [
{ id: 1, name: 'Laptop', price: '$999' },
{ id: 2, name: 'Phone', price: '$599' },
];
return (
<div style={{ padding: '20px' }}>
{products.map((product) => (
<motion.div
key={product.id}
layoutId={`product-${product.id}`}
onClick={() => onSelect(product.id)}
style={{ padding: '15px', border: '1px solid #ccc', marginBottom: '10px', cursor: 'pointer' }}
>
<motion.h4 layoutId={`product-name-${product.id}`}>{product.name}</motion.h4>
<motion.p layoutId={`product-price-${product.id}`}>{product.price}</motion.p>
</motion.div>
))}
</div>
);
}
// Detail view
export function ProductDetail() {
const { productId } = useParams();
const product = { id: productId, name: 'Laptop', price: '$999', description: 'Powerful machine.' };
return (
<motion.div
layoutId={`product-${product.id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ padding: '40px', backgroundColor: '#f9f9f9' }}
>
<motion.h1 layoutId={`product-name-${product.id}`}>{product.name}</motion.h1>
<motion.p layoutId={`product-price-${product.id}`}>{product.price}</motion.p>
<p>{product.description}</p>
</motion.div>
);
}
When navigating from list to detail, the product card animates to fill the detail page layout. Navigating back reverses the animation.
Performance and Best Practices
Do:
- Use
layoutIdsparingly; one per shared element avoids layout thrashing. - Keep
layoutIdvalues consistent across route changes. - Combine with
AnimatePresenceto handle unmounting cleanly. - Test on low-end devices; shared-element animations can be CPU-heavy.
Don't:
- Reuse
layoutIdvalues across unrelated elements (Motion gets confused about which pairs). - Change element dimensions drastically during animation (scale beyond 2x can look janky).
- Animate
layoutIdelements on scroll or every frame (triggers expensive measurements).
Common Issues and Solutions
| Issue | Solution |
|---|---|
| layoutId not animating | Ensure both elements render with identical layoutId and are in the DOM simultaneously during the transition. |
| Jittery or slow animation | Use GPU-safe properties only (transform, opacity). Avoid animating width/height on large images. |
| Layout shifts after animation ends | Ensure the target element doesn't change size after animation completes. Use fixed dimensions. |
| Element flashing at start/end | Wrap in AnimatePresence and use exitBeforeEnter to control render order. |
Key Takeaways
layoutIdlinks visual elements across components for seamless shared-element transitions.- Both elements must render with identical
layoutIdduring the animation for Motion to connect them. - Pair with
AnimatePresenceto coordinate enter/exit lifecycle across route changes. - Use for photos, cards, modals, and multi-step flows where spatial continuity improves UX.
- Test on real devices; shared-element animation is CPU-intensive and affects performance on low-end hardware.
Frequently Asked Questions
Can I animate layoutId between completely different elements (e.g., div to img)?
Technically yes, but avoid it. Motion animates position and size, not element type. Morphing a div to an img will look strange. Keep layoutId elements semantically similar.
What if I have nested layoutId elements?
Each layoutId pair animates independently. A parent and child can both have layoutId; their animations compose. Use this for complex, multi-layer shared-element transitions.
Does layoutId work with conditional rendering?
Yes, if the condition is tied to the animation state. Wrap in AnimatePresence and ensure both elements exist in the DOM during the transition window.
Can I delay or customize layoutId animation duration?
Yes, using the transition prop: <motion.div layoutId="x" transition={{ duration: 0.5 }}>. Defaults to 0.3 s.
How do I prevent scrolling while a layoutId animation is running?
Lock scroll on the body or container while animation plays: useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, [isAnimating]).