Back to Blog
Back to Blog
Tutorials·GSAP·April 25, 2026·8 min read

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.

GSAP page transitions in Next.js App Router — overlay and reveal animation between routes

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:

  1. Overlay animation — an element covers the screen during navigation, masking the instant route change. No exit animation needed.
  2. Enter-only animations — each page animates in on mount. No exit animation. Simple and reliable.
  3. 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>
with a custom component that triggers the animation before navigating:
// 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> ); }
This is my preferred approach for most projects. It's simple, predictable, and doesn't require router coordination. The downside: there's a brief flash between pages while the new page fades in from zero. Adding
clearProps: "all"
removes the inline styles after the animation, so subsequent re-renders aren't affected.

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 inside
useGSAP
or
useEffect
. Don't call
gsap.*
at the top level of a module.
// 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
useGSAP
,
useRef
, or GSAP needs
"use client"
at the top.
"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 means
useGSAP
(and
useEffect
) runs twice. The second run cleans up the first, but you may see animation flickers.

This 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 from
y: 40
, the page may start below the viewport until the animation completes. Fix with
overflow: hidden
on the wrapper, or set the initial position before the animation runs:
useGSAP(() => { // 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 });
Or use
gsap.from()
with
clearProps: "all"
so the element starts in the animated-from state but lands at its natural CSS position.

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 });
With
useGSAP
, this happens automatically for all ScrollTriggers created inside the hook. No manual cleanup needed if you're using
useGSAP
correctly.

A 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
    useGSAP
    or
    useEffect
    . Never at module level in Next.js.
  • Mark any component using GSAP with
    "use client"
    .
  • useGSAP
    automatically cleans up ScrollTriggers and animations on unmount. Don't skip the scope.
  • clearProps: "all"
    removes inline animation styles after completion so CSS takes over.

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

Julian Fella

Founder

Related reading

GSAP useGSAP hook in React and Next.js - animation setup with refs and cleanup
Tutorials·April 21, 2026

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.

Read article
Read article
GSAP hover effects tutorial — magnetic buttons, mouse tracking, and card tilt with GSAP
Tutorials·April 24, 2026

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.

Read article
Read article
GSAP stagger animation tutorial — animating lists and grids with timing offset
Tutorials·April 23, 2026

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.

Read article
Read article

On This Page

Stay ahead of the curve

Join 1000+ creators getting animation updates. Unsubscribe anytime.

Animations
Animations
Pricing
Pricing
Blog
Blog
Showcase
Showcase
Changelog
Changelog
Roadmap
Roadmap
XLinkedInInstagram

© 2026 Annnimate · Built by Good Fella

Privacy
Privacy
Terms
Terms
Cookies
Cookies
Refund
Refund