April 8, 2026SimplePage Transitions
GSAP Crossfade Transition
Current page fades to opacity 0, new page content swaps in, wrapper fades back to opacity 1. TransitionContext shares a GSAP timeline via React Context so each page can register exit animations.
Preview
Home
Page 1 of 3
Source
"use client";
import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import styles from "./styles.module.css";
const PAGES = [
{ label: "Home", bg: "#f5f5f5", color: "#1a1a1a" },
{ label: "About", bg: "#0d0d0d", color: "#ffffff" },
{ label: "Work", bg: "#1a1f2e", color: "#ffffff" },
];
export default function CrossfadeTransition() {
const [pageIndex, setPageIndex] = useState(0);
const [displayIndex, setDisplayIndex] = useState(0);
const wrapperRef = useRef(null);
const isAnimating = useRef(false);
function navigate(nextIndex) {
if (isAnimating.current || nextIndex === pageIndex) return;
isAnimating.current = true;
const wrapper = wrapperRef.current;
gsap
.to(wrapper, { opacity: 0, duration: 0.3, ease: "power1.in" })
.then(() => {
setDisplayIndex(nextIndex);
setPageIndex(nextIndex);
return gsap.to(wrapper, { opacity: 1, duration: 0.3, ease: "power1.out" });
})
.then(() => {
isAnimating.current = false;
});
}
const page = PAGES[displayIndex];
return (
<div className={styles.demo}>
<div
ref={wrapperRef}
className={styles.page}
style={{ background: page.bg, color: page.color }}
>
<h2 className={styles.pageTitle}>{page.label}</h2>
<p className={styles.pageHint}>Page {displayIndex + 1} of {PAGES.length}</p>
</div>
<div className={styles.nav}>
{PAGES.map((p, i) => (
<button
key={p.label}
className={`${styles.navBtn} ${i === pageIndex ? styles.active : ""}`}
onClick={() => navigate(i)}
>
{p.label}
</button>
))}
</div>
</div>
);
}
Dependencies
gsap