Shared-Element Transitions Between Detail Pages
Shared-element transitions between detail pages animate not just one element but multiple related elements in concert. A card's image, title, price, and description can all have layoutId links to their counterparts in a different view, creating a multi-layer shared-element transition. This is more complex than single-element animation but produces stunning results.
The key challenge is ensuring all elements with shared layoutId values render simultaneously in both views so Framer Motion can link them. Careful state management and route handling prevent visual glitches where elements mismatch or animations fail to trigger.
Multi-Element Shared Transitions
In the simplest case, a product card in a list view links to a product detail view. Both contain an image, title, price, and description. By giving each element the same layoutId in both views, Motion animates all four elements from their list positions to detail positions:
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
const products = [
{
id: 1,
title: 'Laptop Pro',
price: '$1,299',
image: '/laptop.jpg',
description: 'Powerful laptop with 16GB RAM and 512GB SSD.',
},
{
id: 2,
title: 'Wireless Mouse',
price: '$49',
image: '/mouse.jpg',
description: 'Ergonomic mouse with 2.4GHz wireless.',
},
];
function ProductCard({ product, onSelect }) {
return (
<motion.div
layoutId={`card-${product.id}`}
onClick={() => onSelect(product.id)}
style={{
padding: '15px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
cursor: 'pointer',
maxWidth: '180px',
}}
>
<motion.img
layoutId={`image-${product.id}`}
src={product.image}
alt={product.title}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: '6px',
marginBottom: '10px',
}}
/>
<motion.h3
layoutId={`title-${product.id}`}
style={{ margin: 0, fontSize: '14px', marginBottom: '5px' }}
>
{product.title}
</motion.h3>
<motion.p
layoutId={`price-${product.id}`}
style={{ margin: 0, fontSize: '16px', fontWeight: 'bold', color: '#0066cc' }}
>
{product.price}
</motion.p>
</motion.div>
);
}
function ProductDetail({ product, 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.6)',
display: 'flex',
alignItems: 'flex-end',
padding: '20px',
}}
>
<motion.div
layoutId={`card-${product.id}`}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '20px',
width: '100%',
maxWidth: '600px',
}}
>
<motion.img
layoutId={`image-${product.id}`}
src={product.image}
alt={product.title}
style={{
width: '100%',
aspectRatio: '16 / 9',
objectFit: 'cover',
borderRadius: '8px',
marginBottom: '20px',
}}
/>
<motion.h2
layoutId={`title-${product.id}`}
style={{ margin: '0 0 10px 0' }}
>
{product.title}
</motion.h2>
<motion.p
layoutId={`price-${product.id}`}
style={{ margin: '0 0 20px 0', fontSize: '20px', fontWeight: 'bold', color: '#0066cc' }}
>
{product.price}
</motion.p>
<motion.p
layoutId={`description-${product.id}`}
style={{ margin: '0 0 20px 0', color: '#666', lineHeight: '1.6' }}
>
{product.description}
</motion.p>
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
onClick={onClose}
style={{
padding: '10px 20px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Close
</motion.button>
</motion.div>
</motion.div>
);
}
export default function ProductCatalog() {
const [selectedId, setSelectedId] = useState(null);
return (
<div style={{ padding: '20px' }}>
<h1>Products</h1>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '20px',
marginBottom: '20px',
}}>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onSelect={setSelectedId}
/>
))}
</div>
<AnimatePresence>
{selectedId && (
<ProductDetail
product={products.find(p => p.id === selectedId)}
onClose={() => setSelectedId(null)}
/>
)}
</AnimatePresence>
</div>
);
}
When you click a card, all four elements (image, title, price, description) animate to their detail page positions simultaneously. The image scales and moves; the title, price, and description reposition and resize. The button fades in after the layout animation completes.
Coordinating Element Sequencing
When multiple elements animate, you can stagger their completion to create visual rhythm. Use transition.delay to sequence elements:
<motion.img
layoutId={`image-${product.id}`}
src={product.image}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
<motion.h2
layoutId={`title-${product.id}`}
transition={{ duration: 0.4, ease: 'easeOut', delay: 0.05 }}
/>
<motion.p
layoutId={`price-${product.id}`}
transition={{ duration: 0.4, ease: 'easeOut', delay: 0.1 }}
/>
The image arrives first, then the title, then the price. This creates a layered entrance effect.
Container vs. Content Animation
Sometimes you want the container to animate (move and resize) while content animates independently. Use layout animation on the container and separate animate props on children:
<motion.div
layoutId={`detail-container-${id}`}
style={{ /* container styles */ }}
>
{/* Container animates position/size */}
<motion.h1
layoutId={`detail-title-${id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
>
{title}
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25 }}
>
{description}
</motion.p>
</motion.div>
The container uses layoutId for position/size animation; the title and description use initial/animate for fade-in. This separates layout motion from content motion.
Avoiding Animation Conflicts
If an element has both layout and an explicit animate prop, layout takes precedence. To avoid conflicts:
- Use
layoutIdfor spatial transforms (position, size). - Use
initial/animatefor opacity, rotation, or skew. - Never mix
layoutIdwith manualanimate={{ x: 100 }}on the same element.
Conditional Content
When detail views contain different content than list views, use layout instead of layoutId to smooth the layout shift:
<motion.div
layout
style={{
padding: selectedId === product.id ? '30px' : '15px',
backgroundColor: selectedId === product.id ? 'white' : '#f5f5f5',
}}
>
{selectedId === product.id && <ExpandedContent />}
</motion.div>
The layout prop animates the padding and background change. Content appears/disappears without breaking the layout animation.
Chaining Multiple Detail Levels
For nested detail views (list → medium → full screen), chain layoutId values across all three levels:
function ListItem() {
return <motion.div layoutId={`item-${id}`} />;
}
function MediumView() {
return <motion.div layoutId={`item-${id}`} />;
}
function FullView() {
return <motion.div layoutId={`item-${id}`} />;
}
Navigating list → medium animates from list size to medium size. Navigating medium → full animates again. Each navigation preserves the layoutId, creating a continuous visual chain.
Performance and Large Transitions
Animating many elements simultaneously (10+ layoutId values) can cause layout thrashing. Profile with Chrome DevTools:
- Measure: Record a frame with Performance tab while opening detail view.
- Optimize: If frame time exceeds 16 ms, reduce animated elements or increase duration.
- Test: Profile on real low-end devices (2-core processor, 4GB RAM).
Strategies to improve performance:
- Reduce the number of
layoutIdelements to 5–7 per transition. - Increase duration slightly (300 → 350 ms) to spread measurement cost over more frames.
- Lazy-load detail content (fetch description only after image animates in).
SEO and Server Rendering
For detail pages with layoutId, ensure server-rendered initial HTML includes both list and detail HTML (typically via conditional rendering). This avoids layout shifts when hydration completes.
With Next.js:
export default function ProductPage() {
const router = useRouter();
const { productId } = router.query;
// Server renders both list and detail; client hydrates with animation
return (
<>
<ProductList />
<AnimatePresence>
{productId && <ProductDetail />}
</AnimatePresence>
</>
);
}
Key Takeaways
- Multiple
layoutIdelements animate together for multi-layer shared-element transitions. - Stagger animations with
transition.delayto sequence element arrival. - Separate container layout animation from content fade-in for visual rhythm.
- Avoid mixing
layoutIdwith manualanimateprops on the same element. - Profile performance with DevTools; limit
layoutIdelements to 5–7 per transition.
Frequently Asked Questions
Can I animate different numbers of elements in list vs. detail?
Yes. The list shows 3 elements (image, title, price); the detail shows 5 (add description and rating). Only shared layoutId values animate; new elements fade in with initial/animate.
What if the detail page layout changes on resize (mobile vs. desktop)?
Use conditional rendering to show different layouts. Framer Motion measures bounding boxes post-layout, so the animation adapts to the current viewport. Test mobile and desktop separately.
Can two elements share the same layoutId at different times?
No. If two elements have the same layoutId simultaneously, Motion gets confused. Ensure only one element per layoutId exists at a time.
How do I animate a carousel or multi-step detail flow?
Use mode="wait" in AnimatePresence to ensure each step completes before the next starts. Combine with individual layoutId values for each step's elements.
Can I interrupt a multi-element animation?
Yes. If the user navigates away while an animation runs, AnimatePresence will exit the detail view and reverse animations. Ensure exit states are defined.