April 8, 2026AdvancedPage Transitions
Pixel Grid Transition
Full-viewport grid of colored blocks that fades in randomly on navigation and out on page load, creating a pixelated wipe effect. Grid density adapts to the container size.
Preview
Home
Source
"use client";
import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import styles from "./styles.module.css";
const PAGES = [
{ key: "home", label: "Home", bg: "#f5f5f5", color: "#111111" },
{ key: "about", label: "About", bg: "#111111", color: "#f5f5f5" },
{ key: "work", label: "Work", bg: "#1a1f2e", color: "#e0e0e0" },
];
function calcGrid(el) {
const w = el.clientWidth;
const h = el.clientHeight;
const cols = w <= 479 ? 4 : w <= 767 ? 6 : 8;
const blockSize = w / cols;
const rows = Math.ceil(h / blockSize);
return { cols, rows, blockSize, total: cols * rows };
}
export default function PixelGridTransition({ color = "#ff4c24" }) {
const demoRef = useRef(null);
const overlayRef = useRef(null);
const isAnimating = useRef(false);
const entranceDone = useRef(false);
const [pageIndex, setPageIndex] = useState(0);
const [grid, setGrid] = useState({ cols: 8, rows: 0, blockSize: 0, total: 0 });
// Recalculate grid whenever the container resizes
useEffect(() => {
const el = demoRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setGrid(calcGrid(el)));
ro.observe(el);
return () => ro.disconnect();
}, []);
// Entrance: play once after the first grid calculation
useEffect(() => {
if (entranceDone.current || grid.total === 0) return;
if (!overlayRef.current) return;
entranceDone.current = true;
const overlay = overlayRef.current;
const blocks = overlay.querySelectorAll("[data-block]");
overlay.style.display = "grid";
gsap.set(blocks, { autoAlpha: 1 });
gsap.to(blocks, {
autoAlpha: 0,
duration: 0.1,
delay: 0.3,
ease: "linear",
stagger: { amount: 0.75, from: "random" },
onComplete: () => { overlay.style.display = "none"; },
});
}, [grid.total]);
function navigate(nextIndex) {
if (isAnimating.current || nextIndex === pageIndex) return;
if (!overlayRef.current) return;
isAnimating.current = true;
const overlay = overlayRef.current;
const blocks = overlay.querySelectorAll("[data-block]");
overlay.style.display = "grid";
// Fade blocks IN → swap page → fade blocks OUT
gsap.fromTo(
blocks,
{ autoAlpha: 0 },
{
autoAlpha: 1,
duration: 0.001,
ease: "linear",
stagger: { amount: 0.5, from: "random" },
onComplete: () => {
setPageIndex(nextIndex);
gsap.to(blocks, {
autoAlpha: 0,
duration: 0.1,
delay: 0.15,
ease: "linear",
stagger: { amount: 0.75, from: "random" },
onComplete: () => {
overlay.style.display = "none";
isAnimating.current = false;
},
});
},
}
);
}
const page = PAGES[pageIndex];
return (
<div ref={demoRef} className={styles.demo}>
{/* Pixel overlay — position: absolute so it stays in the demo container */}
<div
ref={overlayRef}
className={styles.overlay}
style={{
gridTemplateColumns: `repeat(${grid.cols}, 1fr)`,
gridTemplateRows: `repeat(${grid.rows}, ${grid.blockSize}px)`,
}}
>
{Array.from({ length: grid.total }).map((_, i) => (
<div key={i} data-block="" className={styles.block} style={{ backgroundColor: color }} />
))}
</div>
{/* Page content */}
<div className={styles.page} style={{ background: page.bg, color: page.color }}>
<h2 className={styles.pageTitle}>{page.label}</h2>
</div>
{/* Nav */}
<nav className={styles.nav}>
{PAGES.map((p, i) => (
<button
key={p.key}
className={`${styles.navBtn} ${i === pageIndex ? styles.navBtnActive : ""}`}
onClick={() => navigate(i)}
>
{p.label}
</button>
))}
</nav>
</div>
);
}
Dependencies
gsap