Hero Transitions: Animating Images Across Routes
Hero transitions animate an image from a small thumbnail or card position to a full-size hero image in a detail view. The image doesn't teleport; it slides and scales smoothly from its source position to the destination. This is one of the most visually striking animation patterns and is commonly seen in Pinterest, Instagram, and modern e-commerce apps.
The technique uses Framer Motion's layoutId to link the thumbnail and hero image as a continuous visual element. When users tap a thumbnail, the image animates in place while the page content transitions around it, maintaining spatial continuity.
How Hero Transitions Work
A hero transition requires three parts: a source container (thumbnail grid), a target container (detail page), and a shared layoutId on the image element. Framer Motion measures the image's position and size in both contexts, then animates the transform and scale from source to target.
The real challenge is coordinating page transitions with image animation—the detail page must load while the image expands, and all elements must animate in concert.
Basic Photo Gallery with Hero Transition
Here is a thumbnail gallery where clicking an image expands it with a hero transition:
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
export default function PhotoGallery() {
const photos = [
{ id: 1, src: '/photo1.jpg', alt: 'Beach', title: 'Sunset at Beach' },
{ id: 2, src: '/photo2.jpg', alt: 'Mountain', title: 'Mountain Peak' },
{ id: 3, src: '/photo3.jpg', alt: 'Forest', title: 'Forest Trail' },
{ id: 4, src: '/photo4.jpg', alt: 'Ocean', title: 'Ocean Waves' },
];
const [selectedId, setSelectedId] = useState(null);
return (
<div style={{ padding: '20px' }}>
{/* Thumbnail Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: '15px',
}}>
{photos.map((photo) => (
<motion.div
key={photo.id}
layoutId={`hero-${photo.id}`}
onClick={() => setSelectedId(photo.id)}
style={{
cursor: 'pointer',
borderRadius: '8px',
overflow: 'hidden',
aspectRatio: '1',
}}
>
<motion.img
src={photo.src}
alt={photo.alt}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</motion.div>
))}
</div>
{/* Hero Detail View */}
<AnimatePresence>
{selectedId && (() => {
const photo = photos.find(p => p.id === selectedId);
return (
<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.9)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
}}
>
<motion.div
layoutId={`hero-${selectedId}`}
onClick={(e) => e.stopPropagation()}
style={{
width: '90vw',
height: '90vh',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<motion.img
src={photo.src}
alt={photo.alt}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
/>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.15 }}
style={{ color: 'white', marginTop: '20px' }}
>
{photo.title}
</motion.h2>
</motion.div>
);
})()}
</AnimatePresence>
</div>
);
}
Clicking a thumbnail expands the image to full-screen. The image animates from the thumbnail position to the center. The background fades in simultaneously. The title fades in after the image settles (staggered with delay).
E-commerce Card to Detail Flow
Expanding a product card into a detail page is a common pattern. The card container and image both animate:
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
const products = [
{
id: 1,
image: '/product1.jpg',
name: 'Wireless Headphones',
price: '$129',
description: 'High-quality sound with noise cancellation.',
},
{
id: 2,
image: '/product2.jpg',
name: 'Smart Watch',
price: '$199',
description: 'Track fitness and stay connected.',
},
];
function ProductCard({ product, onSelect }) {
return (
<motion.div
layoutId={`product-${product.id}`}
onClick={() => onSelect(product.id)}
style={{
padding: '15px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
cursor: 'pointer',
maxWidth: '200px',
}}
>
<motion.img
layoutId={`product-img-${product.id}`}
src={product.image}
alt={product.name}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: '8px',
marginBottom: '10px',
}}
/>
<motion.h3 layoutId={`product-name-${product.id}`}>{product.name}</motion.h3>
<motion.p layoutId={`product-price-${product.id}`}>{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.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
>
<motion.div
layoutId={`product-${product.id}`}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '30px',
maxWidth: '500px',
width: '100%',
}}
>
<motion.img
layoutId={`product-img-${product.id}`}
src={product.image}
alt={product.name}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: '8px',
marginBottom: '20px',
}}
/>
<motion.h2 layoutId={`product-name-${product.id}`}>{product.name}</motion.h2>
<motion.p layoutId={`product-price-${product.id}`} style={{ fontSize: '20px', fontWeight: 'bold' }}>
{product.price}
</motion.p>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
style={{ marginTop: '15px' }}
>
{product.description}
</motion.p>
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
onClick={onClose}
style={{
marginTop: '20px',
padding: '10px 20px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Close
</motion.button>
</motion.div>
</motion.div>
);
}
export default function ProductGallery() {
const [selectedId, setSelectedId] = useState(null);
return (
<div style={{ padding: '20px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '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>
);
}
The card container, image, title, and price all have layoutId props. When clicked, each animates to its destination position in the detail modal. The description and button fade in after the card settles.
Aspect Ratio Preservation
Images must maintain aspect ratio during hero transitions to avoid stretching. Use objectFit: 'contain' or 'cover' in both source and destination:
| Property | Behavior | Use Case |
|---|---|---|
objectFit: 'cover' | Crops image to fill container; maintains aspect ratio | Photo galleries, thumbnails |
objectFit: 'contain' | Scales image to fit inside container; maintains aspect ratio; may add letterboxing | Product details, hero images |
objectFit: 'fill' | Stretches to fill container; may distort | Backgrounds where distortion is acceptable |
Staggered Metadata Animation
Metadata (title, description, price) should fade in after the image settles. Use transition.delay to stagger:
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
>
Product Title
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25, duration: 0.3 }}
>
Product Description
</motion.p>
This creates a waterfall effect: image arrives, then title, then description.
Mobile Considerations
On smaller screens, the hero transition must account for mobile-sized images and narrow viewports. Test with actual device sizes:
<motion.img
layoutId={`hero-${photo.id}`}
src={photo.src}
srcSet={`${photo.src}?w=300 300w, ${photo.src}?w=600 600w, ${photo.src}?w=1200 1200w`}
sizes="(max-width: 640px) 90vw, 500px"
alt={photo.alt}
style={{
width: '100%',
height: 'auto',
objectFit: 'cover',
}}
/>
Responsive images with srcSet load appropriately sized assets on mobile.
Performance: Lazy Loading
For large image galleries, lazy-load images outside the viewport to reduce initial load:
import { useInView } from 'react-intersection-observer';
function LazyImage({ src, alt, layoutId }) {
const { ref, inView } = useInView({ triggerOnce: true });
return (
<div ref={ref}>
{inView ? (
<motion.img layoutId={layoutId} src={src} alt={alt} />
) : (
<div style={{ aspectRatio: '1', backgroundColor: '#ddd' }} />
)}
</div>
);
}
Images only load when scrolled into view, improving Core Web Vitals.
Key Takeaways
- Hero transitions link thumbnail and detail images with shared
layoutIdfor seamless animation. - Use
objectFit: 'contain'or'cover'to preserve aspect ratio across screen sizes. - Stagger metadata animation (
initial,delay) to arrive after image settles. - Lazy-load large image galleries to improve performance.
- Test on real mobile devices; aspect ratios and animation smoothness differ by device class.
Frequently Asked Questions
What if the thumbnail and detail image have different aspect ratios?
Use objectFit: 'cover' for thumbnails (crops to square) and objectFit: 'contain' for detail (shows full image). The animation will smoothly scale between the two.
Can hero transitions work with responsive images (srcSet)?
Yes. Use srcSet and sizes on both thumbnail and detail images. Framer Motion measures the rendered size, not the source resolution.
Does zooming the browser affect hero transitions?
The animation measures bounding boxes in viewport coordinates, so browser zoom is respected. Test at multiple zoom levels (100%, 110%, 125%).
How do I avoid image flashing or flickering during transition?
Ensure both images are loaded before the animation starts. Use the onLoad callback to delay animation start, or preload images with <link rel="preload">.
Can I nest hero transitions (thumbnail → medium → full)?
Technically yes, but avoid deep nesting. Each layer of layoutId adds measurement overhead. Limit to 2–3 levels.