Skip to main content

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.

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:

PropertyBehaviorUse Case
objectFit: 'cover'Crops image to fill container; maintains aspect ratioPhoto galleries, thumbnails
objectFit: 'contain'Scales image to fit inside container; maintains aspect ratio; may add letterboxingProduct details, hero images
objectFit: 'fill'Stretches to fill container; may distortBackgrounds 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 layoutId for 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.

Further Reading