GSAP Page Transitions in Next.js: A Practical Guide (2026)
Learn how to build smooth GSAP page transitions in Next.js App Router. Covers overlay animations, exit animations, useGSAP cleanup, and common pitfalls.

Estimated reading time: 11 minutes | Skill level: Intermediate to Advanced
Page transitions are one of the most impactful ways to elevate a Next.js project. They signal craft. Done well, they make navigation feel like movement through a space rather than a hard cut. Done poorly, they make the site feel sluggish.
The hard part isn't the animation itself. It's timing it correctly with Next.js's router. This guide covers how to do it without fighting the framework.
Why Page Transitions Are Tricky in Next.js
Next.js App Router renders new pages instantly. By the time you want to play an exit animation, the route has already changed and the old page is gone.
The approaches that work:
- Overlay animation — an element covers the screen during navigation, masking the instant route change. No exit animation needed.
- Enter-only animations — each page animates in on mount. No exit animation. Simple and reliable.
- View Transitions API — browser-native, progressively enhanced. No GSAP needed for basic use.
Most production sites use approach 1 or 2. Approach 3 is worth knowing but has browser support limitations.
Approach 1: Overlay Animation (Most Reliable)
An overlay div sits on top of everything. On navigation, it slides in, the route changes underneath, then it slides out. The hard cut is invisible.
The Setup
In your root layout, add an overlay element and a context/provider to trigger it:
// app/layout.js
import TransitionOverlay from "@/components/TransitionOverlay";
import TransitionProvider from "@/components/TransitionProvider";
export default function RootLayout({ children }) {
return (
<html>
<body>
<TransitionProvider>
{children}
<TransitionOverlay />
</TransitionProvider>
</body>
</html>
);
}
The Provider
// components/TransitionProvider.js
"use client";
import { createContext, useContext, useRef } from "react";
const TransitionContext = createContext(null);
export function useTransition() {
return useContext(TransitionContext);
}
export default function TransitionProvider({ children }) {
const overlayRef = useRef(null);
async function navigate(href, router) {
// Animate overlay in
await new Promise((resolve) => {
gsap.to(overlayRef.current, {
scaleY: 1,
duration: 0.5,
ease: "expo.inOut",
transformOrigin: "bottom",
onComplete: resolve
});
});
// Change route (the hard cut happens here, hidden by overlay)
router.push(href);
// Small delay for new page to mount
await new Promise((r) => setTimeout(r, 100));
// Animate overlay out
gsap.to(overlayRef.current, {
scaleY: 0,
duration: 0.5,
ease: "expo.inOut",
transformOrigin: "top"
});
}
return (
<TransitionContext.Provider value={{ overlayRef, navigate }}>
{children}
</TransitionContext.Provider>
);
}
The Overlay Component
// components/TransitionOverlay.js
"use client";
import { useRef } from "react";
import { useTransition } from "./TransitionProvider";
export default function TransitionOverlay() {
const { overlayRef } = useTransition();
return (
<div
ref={overlayRef}
style={{
position: "fixed",
inset: 0,
backgroundColor: "var(--color-background)",
zIndex: 9999,
scaleY: 0,
transformOrigin: "bottom"
}}
/>
);
}
The TransitionLink Component
Replace<Link>// components/TransitionLink.js
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "./TransitionProvider";
export default function TransitionLink({ href, children, ...props }) {
const router = useRouter();
const { navigate } = useTransition();
function handleClick(e) {
e.preventDefault();
navigate(href, router);
}
return (
<a href={href} onClick={handleClick} {...props}>
{children}
</a>
);
}
Usage:
<TransitionLink href="/about">About</TransitionLink>
Visual: PLACEHOLDER - Diagram showing overlay animation: slide in → route change (hidden) → slide out
Approach 2: Enter-Only Animations
The simplest approach. Every page animates in on mount. No exit animation. No coordination with the router needed.
Create a wrapper component that plays on mount:
// components/PageTransition.js
"use client";
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
export default function PageTransition({ children }) {
const wrapperRef = useRef(null);
useGSAP(() => {
gsap.from(wrapperRef.current, {
opacity: 0,
y: 20,
duration: 0.6,
ease: "power3.out",
clearProps: "all" // remove inline styles when done
});
}, { scope: wrapperRef });
return (
<div ref={wrapperRef}>
{children}
</div>
);
}
Wrap each page with it:
// app/about/page.js
import PageTransition from "@/components/PageTransition";
export default function AboutPage() {
return (
<PageTransition>
<h1>About</h1>
{/* ... */}
</PageTransition>
);
}
clearProps: "all"Visual: PLACEHOLDER - Side-by-side showing page enter animation with y offset fade vs instant hard cut
Common Pitfalls
1. Animating During SSR
GSAP only runs in the browser. All GSAP code must be insideuseGSAPuseEffectgsap.*// Wrong — will break on server render
import gsap from "gsap";
gsap.to(".page", { opacity: 1 }); // runs during SSR, fails
// Right — inside useGSAP, client-only
useGSAP(() => {
gsap.to(".page", { opacity: 1 });
});
2. Forgetting "use client"
In Next.js App Router, components are Server Components by default. Any component that uses "use client"useGSAPuseRef"use client""use client"; // required
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
3. Animations Running Twice in Development
React 18's Strict Mode mounts components twice in development. This meansuseGSAPuseEffectThis only happens in development. Production builds don't double-mount. Don't work around it — it's expected behavior for catching cleanup bugs.
4. Scrolling Issues After Transition
When GSAP animates a page element fromy: 40overflow: hiddenuseGSAP(() => {
// Set initial state explicitly
gsap.set(wrapperRef.current, { opacity: 0, y: 20 });
gsap.to(wrapperRef.current, {
opacity: 1,
y: 0,
duration: 0.6,
ease: "power3.out",
clearProps: "all"
});
}, { scope: wrapperRef });
gsap.from()clearProps: "all"5. ScrollTrigger Not Refreshing
After a page transition, ScrollTrigger doesn't know the layout has changed. Existing ScrollTriggers from the previous page may interfere. Always kill ScrollTriggers in the cleanup:
useGSAP(() => {
const st = ScrollTrigger.create({
trigger: ".section",
start: "top 80%",
onEnter: () => gsap.to(".section", { opacity: 1 })
});
return () => st.kill(); // cleanup on page unmount
}, { scope: containerRef });
useGSAPuseGSAPA Complete Page Enter Animation
Here's a production-ready pattern I use for page enters. It staggers the main heading, body text, and any hero image:
"use client";
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
gsap.registerPlugin(useGSAP);
export default function PageEnter({ children }) {
const pageRef = useRef(null);
useGSAP(() => {
const tl = gsap.timeline({
defaults: { ease: "power3.out" }
});
// Stagger all immediate children
tl.from(pageRef.current.children, {
y: 30,
opacity: 0,
duration: 0.7,
stagger: 0.1,
clearProps: "all"
});
}, { scope: pageRef });
return <div ref={pageRef}>{children}</div>;
}
This animates each direct child of the page wrapper with a 0.1s stagger. It works for most page layouts without needing per-element refs.
View Transitions API (No GSAP Required)
For basic cross-fade transitions, the View Transitions API requires no JavaScript animation library:
@view-transition {
navigation: auto;
}
/* Customize the transition */
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-out;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
Browser support: Chrome 111+, Edge 111+. Safari and Firefox support is in progress. If you need broad compatibility, GSAP approaches are more reliable. If you're targeting modern Chrome or can use progressive enhancement, View Transitions is worth knowing.
Key Takeaways
- Overlay animation is the most reliable: cover the screen, change the route, reveal the new page.
- Enter-only animations are the simplest: animate in on mount, no exit needed.
- All GSAP code must be inside or
useGSAP. Never at module level in Next.js.useEffect - Mark any component using GSAP with .
"use client" - automatically cleans up ScrollTriggers and animations on unmount. Don't skip the scope.
useGSAP - removes inline animation styles after completion so CSS takes over.
clearProps: "all"
Take It Further
For more GSAP in React patterns, the useGSAP Hook Guide covers scoping, contextSafe callbacks, and cleanup in detail.
For scroll animations that run after the page transition completes, GSAP ScrollTrigger Examples covers 10 patterns including how to handle refresh after navigation.
The Annnimate library includes a page transition animation component with multiple styles (fade, wipe, overlay) as ready-to-use React code.
Written by
Julian Fella
Founder
Related reading

GSAP in React: How to Use the useGSAP Hook (2026)
Learn how to use GSAP in React with the useGSAP hook. Covers setup, refs, scoping, contextSafe callbacks, ScrollTrigger cleanup, and Next.js SSR patterns.

GSAP Hover Effects: 5 Patterns Worth Knowing (2026)
Learn how to build GSAP hover effects that feel polished. Covers play/reverse pattern, magnetic effects, quickTo for mouse tracking, and React implementation.

GSAP Stagger: Animate Lists and Grids with Rhythm (2026)
Learn how to use GSAP stagger to animate multiple elements with perfect timing. Covers basic stagger, advanced object syntax, from options, and real-world grid reveals.