Framer Motion Page Transitions: Full Next.js App Router Example
Wire up Framer Motion page transitions in Next.js App Router — real code, AnimatePresence gotchas, layout shift fixes, and performance tips developers actually need.
Why Page Transitions Are Still Annoying in Next.js App Router
Honestly, the App Router made a lot of things better and page transitions significantly harder. Pages router had a single _app.tsx where you could drop AnimatePresence and call it a day. App Router's nested layouts changed the game entirely — and not in a good way for animation.
The problem is structural. Each route segment is a React Server Component subtree. AnimatePresence needs to track when children mount and unmount, but RSC boundaries mean the component tree doesn't behave the way Framer Motion expects. You'll run into cases where the exit animation just... doesn't fire.
This article walks through a working setup for Framer Motion 11.x with Next.js 14+ App Router. Real code. Actual gotchas documented with fixes. No hand-waving about 'just wrap it in a motion div.'
Installing Framer Motion 11 and Setting Up the Template
Start clean. Run npm install framer-motion@11 — at time of writing, 11.3.8 is the latest stable. Don't pin to a minor if you're starting a new project; the 11.x line has meaningful performance improvements over 10.x, especially around layout animations and the new useAnimate hook.
Your file structure needs two key pieces: a client-side PageTransition wrapper component, and the AnimatePresence placed correctly in your root layout. Most tutorials get the placement wrong and that's where the exit animations break.
Create app/components/PageTransition.tsx as a client component. The 'use client' directive is non-negotiable here — Framer Motion hooks don't work in server components and Next.js will throw during build if you try.
The AnimatePresence Placement Problem — and the Fix
Here's the real issue: AnimatePresence watches its direct children for mount/unmount. In App Router, your page content renders inside {children} in layout.tsx, but the route change doesn't unmount and remount children the way you'd expect — it might re-render in place depending on shared layout segments.
The fix is using usePathname() from next/navigation as the key prop. This forces React to treat each route as a distinct component instance, which gives AnimatePresence the unmount signal it needs to run exit animations.
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { usePathname } from 'next/navigation';
const variants = {
hidden: { opacity: 0, y: 16 },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -16 },
};
export default function PageTransition({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
variants={variants}
initial="hidden"
animate="enter"
exit="exit"
transition={{ duration: 0.25, ease: 'easeInOut' }}
style={{ position: 'relative' }}
>
{children}
</motion.div>
</AnimatePresence>
);
}Two things to notice: mode="wait" on AnimatePresence makes the exit animation finish before the enter starts — without this you get both animations running simultaneously and it looks broken. And initial={false} prevents the enter animation from firing on the very first page load, which would make your site feel janky on arrival.
Wiring PageTransition into Your Root Layout
Your app/layout.tsx stays a server component — that's fine. You import the client PageTransition wrapper and use it around {children}. This is the correct layering: server layout → client transition wrapper → page content.
// app/layout.tsx (Server Component — no 'use client')
import PageTransition from '@/components/PageTransition';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<nav>{/* your nav here */}</nav>
<main>
<PageTransition>{children}</PageTransition>
</main>
</body>
</html>
);
}One thing that trips people up: if you have nested layouts (e.g., app/dashboard/layout.tsx), you need to decide whether transitions should happen at the root level or the nested level. Putting AnimatePresence at both levels leads to competing animations. Pick one. For most apps, root level is the right call.
Also watch your <main> element's CSS. If it has overflow: hidden without a set height, the exit animation (the page sliding upward with y: -16) can cause a visible clip. Add min-height: 100dvh or remove the overflow constraint entirely.
Customizing Transition Variants Without Breaking Performance
The default fade + slide is fine but you'll want something more distinctive eventually. The variants object is where you control everything. A few patterns worth knowing: slide-from-right feels native on mobile, crossfade works best for content-heavy pages, and a subtle scale (scale: 0.98 on exit) adds depth without being distracting.
Keep your transition duration under 300ms for navigation. Anything longer and users feel the interface is slow, regardless of how smooth the animation is. For reference, iOS system transitions run at roughly 250ms — that's a solid benchmark. Our setup above uses 0.25s which hits that mark.
What about will-change? Framer Motion handles GPU compositing automatically for opacity and transform properties. You don't need to manually set will-change: transform — adding it yourself can actually increase memory consumption without benefit. Trust the library on this one. If you want to pair these transitions with animated backgrounds like a particles background or an aurora background effect, make sure those are outside the PageTransition wrapper so they persist across route changes.
Handling Layout Shift During Transitions
Layout shift is the sneaky problem with page transitions. As the exit animation runs, the page height changes because content is leaving the DOM. This causes the footer to jump up, scrollbars to disappear and reappear, and the incoming page to render at an incorrect scroll position.
The scroll position fix is straightforward: add a useEffect that calls window.scrollTo(0, 0) on pathname change. Do this inside your PageTransition component, triggered by the pathname variable.
import { useEffect } from 'react';
// Inside PageTransition component:
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
}, [pathname]);For the height jump, you have two options. Wrap the motion.div in a container with position: relative and min-height: 100dvh. Or use Framer Motion's layout prop on a parent element to animate height changes automatically. The min-height approach is simpler and introduces no additional animation cost. Combined with a theme toggle that persists across routes, the overall experience feels genuinely polished.
Testing Transitions Across Shared Layout Segments
App Router only re-renders the segments that change between routes. If /dashboard and /dashboard/settings share app/dashboard/layout.tsx, navigating between them won't remount the dashboard layout — only the page-level content changes. This is great for performance but it means your transition only fires for the page segment, not the full screen.
Is that actually a problem? Usually not. Most of the time you want shared UI like sidebars and nav bars to stay stable during transitions anyway. The motion only needs to apply to the content area. But if you're building something where the full screen should animate — like a landing page with full-viewport sections — you might want the transition wrapper at the page level instead of the root level.
To validate this is working correctly, open Chrome DevTools, go to the Rendering tab, and enable 'Paint flashing.' You should see green flashes only on the content area during transitions, not on your nav or shared layout elements. If you see flashing everywhere, your wrapper placement is wrong.
Common Errors and How to Diagnose Them
Exit animations not running at all. This almost always means AnimatePresence isn't seeing the unmount. Check that your motion.div has a key prop that changes on navigation. If you're using mode="wait" and exits still don't fire, add a console.log in an onExitComplete callback on AnimatePresence — if it never logs, the exit animation isn't starting.
`window is not defined` during build. You referenced window inside a component without a 'use client' directive, or you called it at module scope instead of inside useEffect. Framer Motion itself is fine with SSR, but anything touching window directly needs to be gated behind useEffect or the 'use client' boundary.
Transition fires on initial load despite `initial={false}`. Usually happens when you have a duplicate AnimatePresence somewhere in the tree. Search your codebase for all instances — two nested AnimatePresence components cause unpredictable behavior. For background effects that complement your transitions, check out shooting stars or a spotlight effect — both work well outside the transition wrapper as persistent scene elements.
Layout breaks on mobile during exit. The y offset on exit (y: -16) can interact badly with position: fixed elements like mobile navbars. Either set position: relative on the motion.div and ensure the fixed elements are siblings, not children, or switch to a pure opacity transition that doesn't affect layout geometry.
FAQ
Not quite. You need to add a key={pathname} prop (using usePathname() from next/navigation) to the motion.div inside AnimatePresence. Without it, App Router's partial re-rendering means AnimatePresence never sees an unmount event and exit animations won't fire.
Two common causes: missing key prop on the motion.div (see above), or not using mode="wait" on AnimatePresence. Without mode="wait", the incoming page renders immediately and React may unmount the exiting component before the animation finishes.
Yes, but be careful. Layout animations use layoutId to match elements across renders. If you're transitioning between pages that share a layoutId element — like a shared hero image — Framer Motion will animate the element between positions. This is the 'magic motion' pattern. It works well but adds complexity; test on lower-end mobile devices since layout animations are more CPU-intensive than simple opacity/transform transitions.
Add a useEffect inside your PageTransition component that calls window.scrollTo({ top: 0, behavior: 'instant' }) whenever the pathname changes. Use behavior: 'instant' rather than 'smooth' — smooth scrolling combined with a page transition animation creates a confusing double-motion effect.
Minimal if you stick to opacity and transform (translate, scale) properties — those are GPU-composited and don't trigger layout or paint. Avoid animating height, width, top, left, or margin in your transition variants. The library size is roughly 43KB gzipped for the full bundle, which you can reduce by importing only from framer-motion/dom if you don't need the React-specific helpers.
Just the root in most cases. Multiple nested AnimatePresence wrappers conflict and produce unpredictable results. The exception is if different sections of your app need completely different transition styles — in that case, remove the root-level AnimatePresence and add separate ones per layout segment, but never nest them.