Building Smooth Page Transitions with React
Page transitions in React Router happen when users navigate between routes. Without animation, the old page disappears and the new one appears instantly—jarring and uninformative. With animation, route changes feel fluid and establish spatial relationships between views.
The key challenge is coordinating lifecycle events: the old route must stay in the DOM long enough to animate out, and the new route must animate in before users interact. Framer Motion's AnimatePresence combined with React Router's useLocation hook solves this elegantly.
Why Route Transitions Matter
When a user navigates from a list page to a detail page, animation clarifies the relationship: is the detail a child of the list, or an independent view? A slide-up animation suggests depth; a fade suggests replacement. This visual language helps users understand the app's structure and reduces disorientation.
Studies show animated route transitions reduce perceived load time by up to 20% and increase user confidence in navigation. Slow or missing transitions make apps feel broken.
Basic Fade Transition
Here is the simplest pattern: fade out the old page, fade in the new one:
import { motion, AnimatePresence } from 'framer-motion';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
function HomePage() {
return <h1>Home Page</h1>;
}
function AboutPage() {
return <h1>About Page</h1>;
}
function AppRoutes() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Routes location={location}>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</motion.div>
</AnimatePresence>
);
}
export default function App() {
return (
<BrowserRouter>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<AppRoutes />
</BrowserRouter>
);
}
The key={location.pathname} forces the motion wrapper to remount on each navigation, triggering the initial state. The old page fades out, then the new page fades in.
Slide Transition (Directional)
To signal forward/backward navigation, slide the pages in opposite directions:
import { motion, AnimatePresence } from 'framer-motion';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { useRef, useEffect, useState } from 'react';
function AppRoutes() {
const location = useLocation();
const [isForward, setIsForward] = useState(true);
const prevPathRef = useRef(location.pathname);
useEffect(() => {
const pathIndex = { '/': 0, '/about': 1, '/contact': 2 };
const currentIndex = pathIndex[location.pathname] || 0;
const prevIndex = pathIndex[prevPathRef.current] || 0;
setIsForward(currentIndex > prevIndex);
prevPathRef.current = location.pathname;
}, [location.pathname]);
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, x: isForward ? 50 : -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isForward ? -50 : 50 }}
transition={{ duration: 0.3 }}
>
<Routes location={location}>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
</Routes>
</motion.div>
</AnimatePresence>
);
}
Navigating forward slides the old page left and the new page in from the right. Navigating backward reverses the direction. This gives users a mental model of navigation history.
Shared-Element Across Routes
Combining route animation with layoutId creates seamless element transitions across pages. A product card on a list expands into a detail page:
import { motion, AnimatePresence } from 'framer-motion';
import { BrowserRouter, Routes, Route, useLocation, useParams } from 'react-router-dom';
import { useState } from 'react';
const products = [
{ id: 1, name: 'Laptop', price: '$999' },
{ id: 2, name: 'Phone', price: '$699' },
];
function ProductList() {
return (
<div style={{ padding: '20px' }}>
{products.map((product) => (
<motion.a
key={product.id}
href={`/product/${product.id}`}
layoutId={`product-card-${product.id}`}
style={{
display: 'block',
padding: '15px',
border: '1px solid #ccc',
marginBottom: '10px',
borderRadius: '8px',
textDecoration: 'none',
color: 'inherit',
}}
>
<motion.h3 layoutId={`product-name-${product.id}`}>{product.name}</motion.h3>
<motion.p layoutId={`product-price-${product.id}`}>{product.price}</motion.p>
</motion.a>
))}
</div>
);
}
function ProductDetail() {
const { productId } = useParams();
const product = products.find((p) => p.id === parseInt(productId));
if (!product) return <div>Product not found</div>;
return (
<motion.div
layoutId={`product-card-${product.id}`}
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>Detailed product information goes here.</p>
</motion.div>
);
}
function AppRoutes() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Routes location={location}>
<Route path="/" element={<ProductList />} />
<Route path="/product/:productId" element={<ProductDetail />} />
</Routes>
</motion.div>
</AnimatePresence>
);
}
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
Clicking a product animates the card to the detail page layout. The product name and price both have layoutId and animate in concert.
Overlay Route Transitions
Some routes should overlay the current page (e.g., modals from a modal route). Use conditional rendering to overlay without replacing:
import { motion, AnimatePresence } from 'framer-motion';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
function HomePage() {
return <h1>Home Page</h1>;
}
function SettingsModal() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => window.history.back()}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
}}
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'white',
padding: '30px',
borderRadius: '12px',
maxWidth: '400px',
}}
>
<h2>Settings</h2>
<p>Your settings options go here.</p>
<button onClick={() => window.history.back()}>Close</button>
</motion.div>
</motion.div>
);
}
function AppRoutes() {
const location = useLocation();
return (
<>
<Routes location={location}>
<Route path="/" element={<HomePage />} />
</Routes>
<AnimatePresence>
<Routes location={location}>
<Route path="/settings" element={<SettingsModal />} />
</Routes>
</AnimatePresence>
</>
);
}
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
The location parameter passed to Routes ensures both route sets respond to navigation. Modal routes render overlays; content routes render the main page.
Nested Route Transitions
For nested routes (parent and child pages), animate only the child route changing:
function ParentLayout() {
const location = useLocation();
return (
<div>
<nav>{/* Navigation */}</nav>
<AnimatePresence mode="wait">
<motion.main
key={location.pathname}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<Outlet />
</motion.main>
</AnimatePresence>
</div>
);
}
The parent stays fixed; only the child page animates. This works well for tabbed interfaces and dashboard layouts.
Performance Considerations
Large route transitions can block the main thread if the new page is complex. Profile with DevTools Performance tab. If frame rate drops below 60 FPS, reduce transition duration, disable animation on low-end devices, or lazy-load route components:
import { lazy, Suspense } from 'react';
const ProductDetail = lazy(() => import('./ProductDetail'));
<Suspense fallback={<div>Loading...</div>}>
<ProductDetail />
</Suspense>
Key Takeaways
- Page transitions coordinate route lifecycle with animation to create fluid navigation.
- Use
AnimatePresencewithuseLocationto detect route changes and animate accordingly. - Directional slides (forward/backward) give users a mental model of navigation history.
- Combine
layoutIdwith route transitions for shared-element animations across pages. - Profile on real devices; complex pages can jank if animation overlaps with heavy rendering.
Frequently Asked Questions
How do I handle back button navigation differently?
Track the previous route index and calculate isForward based on whether the new route index is greater. The code example above shows this pattern.
Can I animate only certain routes?
Yes. Check the pathname in your animation logic and conditionally apply animation or vary transition timing per route.
What if a route change happens too fast?
Use mode="wait" to ensure the exit animation completes before entering starts. This prevents overlapping, janky animations.
Should I animate route transitions on mobile?
Yes, but with shorter durations (250 ms vs. 300 ms) to feel snappy. Respect prefers-reduced-motion for accessibility.
How do I handle lazy-loaded route components?
Wrap the lazy component in Suspense with a loading fallback. The fallback can also have animations for entry/exit.