Creating Page Transition Animations
Page transition animations make navigation feel like a continuous process, not a series of teleports. Properly implemented transitions reduce perceived load time and give users spatial context ("I went deeper" vs "I came back"). Done wrong — they irritate with delays and conflict with router logic.
Approach by Stack
| Stack | Approach |
|---|---|
| React + React Router | framer-motion + AnimatePresence |
| Next.js App Router | View Transitions API or framer-motion |
| Vue / Nuxt | <Transition> + <TransitionGroup> |
| Astro | View Transitions API natively |
| Multi-page site (MPA) | View Transitions API |
Framer Motion + React Router
Basic scheme: wrap <Routes> in <AnimatePresence>, each screen is a <motion.div> with animation variants:
import { AnimatePresence, motion } from 'framer-motion';
import { useLocation, Routes, Route } from 'react-router-dom';
const pageVariants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
};
const pageTransition = {
type: 'tween',
ease: 'anticipate',
duration: 0.25,
};
export const AnimatedRoutes: React.FC = () => {
const location = useLocation();
return (
<AnimatePresence mode="wait" initial={false}>
<Routes location={location} key={location.pathname}>
<Route path="/" element={<PageWrapper><Home /></PageWrapper>} />
<Route path="/about" element={<PageWrapper><About /></PageWrapper>} />
<Route path="/catalog" element={<PageWrapper><Catalog /></PageWrapper>} />
</Routes>
</AnimatePresence>
);
};
const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<motion.div
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={pageTransition}
>
{children}
</motion.div>
);
mode="wait" ensures the old page fully exits before the new one appears. mode="sync" — both animate simultaneously (faster, but can look chaotic).
Directional Transitions (Forward/Back)
For hierarchical navigation (catalog → product → cart), transitions should be directional: forward — slide right, back — slide left.
const useNavigationDirection = () => {
const [direction, setDirection] = useState(0);
const location = useLocation();
const prevLocation = useRef(location);
const navHistory = useRef<string[]>([location.pathname]);
useEffect(() => {
const currentPath = location.pathname;
const history = navHistory.current;
const prevIndex = history.lastIndexOf(prevLocation.current.pathname);
const currentIndex = history.indexOf(currentPath);
if (currentIndex > prevIndex) setDirection(1); // forward
else setDirection(-1); // back
if (currentIndex === -1) {
navHistory.current = [...history, currentPath];
}
prevLocation.current = location;
}, [location]);
return direction;
};
// Animation variant with direction
const variants = {
initial: (dir: number) => ({ x: dir > 0 ? '100%' : '-100%', opacity: 0 }),
animate: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir > 0 ? '-100%' : '100%', opacity: 0 }),
};
View Transitions API (Native Browser Approach)
Supported in Chrome 111+, Safari 18+. For MPA and Next.js — minimal code:
// Navigation wrapper
async function navigateTo(url) {
if (!document.startViewTransition) {
window.location.href = url;
return;
}
const transition = document.startViewTransition(async () => {
const html = await fetch(url).then(r => r.text());
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('main').replaceWith(doc.querySelector('main'));
history.pushState({}, '', url);
});
await transition.ready;
}
CSS for controlling animation:
/* Override default cross-fade */
@keyframes slide-from-right {
from { transform: translateX(100%); }
}
@keyframes slide-to-left {
to { transform: translateX(-100%); }
}
::view-transition-old(root) {
animation: 250ms ease slide-to-left;
}
::view-transition-new(root) {
animation: 250ms ease slide-from-right;
}
/* For specific elements — shared element transition */
.product-image {
view-transition-name: product-hero;
}
Shared element transitions — hero animations where a specific element (product card) smoothly "transforms" into the hero image on the product page. This is the most impressive View Transitions API feature.
Skeleton Screens Instead of Spinners
During transitions, data often loads asynchronously. Spinners show "loading" — skeletons show page structure:
const ProductSkeleton: React.FC = () => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="skeleton-wrapper"
>
<div className="skeleton skeleton--image" />
<div className="skeleton skeleton--title" />
<div className="skeleton skeleton--text" />
<div className="skeleton skeleton--text skeleton--short" />
</motion.div>
);
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Canceling Animations on Fast Navigation
If the user clicks quickly, animations should interrupt, not queue up:
// framer-motion handles this automatically via AnimatePresence
// For custom animations, use useAnimation:
const controls = useAnimation();
const navigate = async (to: string) => {
await controls.start('exit'); // wait for exit
router.push(to);
};
// Or just shorten duration to 150-200ms
// which makes interruption imperceptible to users
Accessibility
Animations can be unpleasant for people with vestibular disorders. Required media query:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-image-pair(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
In framer-motion:
const shouldReduceMotion = useReducedMotion();
const transition = shouldReduceMotion
? { duration: 0 }
: { duration: 0.25, ease: 'easeInOut' };
Timeline
| Task | Time |
|---|---|
| Basic fade/slide transitions (framer-motion) | 0.5 day |
| Directional forward/back transitions | 1 day |
| View Transitions API + shared elements | 1–2 days |
| Skeleton screens for 3–5 templates | 1 day |







