Block Grid Page Transitions — A 10×11 Grid of Blocks Curtains Over the Viewport to Swap Pages
Goal
Build a tiny single-page "fake router" site (three virtual pages: Index / About / Contact) with a fixed top navbar and one giant centered heading. The star effect is the page transition: clicking a nav link fires a two-phase GSAP sequence over a fullscreen 10-row × 11-column grid of solid blocks. Cover phase — a grid of blocks *grows up from the bottom* (scaleY: 0 → 1, transform-origin: bottom), each block starting on its own per-row random delay, until the whole viewport is blanketed. The heading text is swapped underneath at the exact moment of full coverage. Reveal phase — a *second* grid of blocks (already covering) *shrinks up toward the top* (scaleY: 1 → 0, transform-origin: top) with the same per-block random stagger, uncovering the new page. The same reveal also plays once on initial load as a page-in. Every block tween uses a GSAP CustomEase reproduction of the cubic-bezier(0.22, 1, 0.36, 1) snappy ease over a fixed 1s duration. It is entirely click-driven — no scroll, no autoplay.
Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
gsap(3.x) plus the single GSAP pluginCustomEase. No ScrollTrigger, no SplitText, no Lenis/smooth-scroll, no Three.js.
import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
gsap.registerPlugin(CustomEase);
Layout / HTML
A .nav bar, a .container holding the single <h1>, and two empty fullscreen .blocks-container overlays (.transition-in and .transition-out) that the JS fills with the block grid. Class names and the data-page attributes are load-bearing (the JS queries them).
<div class="nav">
<div class="logo">
<a class="nav-link" href="#" data-page="index">Ink_Octopus</a>
</div>
<div class="nav-links">
<div class="nav-item"><a class="nav-link" href="#" data-page="index">Home</a></div>
<div class="nav-item"><a class="nav-link" href="#" data-page="about">About</a></div>
<div class="nav-item"><a class="nav-link" href="#" data-page="contact">Contact</a></div>
</div>
</div>
<div class="container">
<h1>Index</h1>
</div>
<div class="blocks-container transition-in"></div>
<div class="blocks-container transition-out"></div>
<script type="module" src="./script.js"></script>
Notes on structure:
- The logo is a link to
data-page="index"with fictional textInk_Octopus; the three nav links carrydata-page="index" | "about" | "contact"with visible labels Home / About / Contact. All four are.nav-link(all are clickable route triggers). - Both
.blocks-containerdivs start empty — the JS injects 10.rowdivs, each with 11.blockdivs, into each container (so two full grids exist, one for covering, one for revealing). - The
<h1>initial text isIndex; it is rewritten by JS during a transition (never on click directly). - Use neutral demo labels only — no real brand names.
Styling
Global reset applies a text color to everything: * { margin:0; padding:0; box-sizing:border-box; color:#0f0f0f; }.
Fonts: the heading uses "PP Monument Extended" (a wide/extended heavy grotesque display face) and the nav links use "PP Supply Mono" (a monospace). These are commercial; the original declares them by name with no @font-face (graceful fallback). For a runnable reproduction, either load look-alike free fonts or supply fallbacks — a very wide/expanded bold sans for the heading (e.g. Archivo Expanded / a condensed-inverse wide grotesque) and any monospace (e.g. Space Mono / DM Mono) for the nav. Keep the family *names* in CSS as written so the intent is clear.
html, body { font-family: "PP Monument Extended"; background: #ffffff; }
Palette (exact hex):
- Page background:
#ffffff(white) - Text / global color:
#0f0f0f(near-black); nav anchors explicitly#000 - Block fill:
#667067(a muted sage grey-green) — this is the curtain color that flashes across the screen.
Load-bearing CSS:
.nav:position: fixed; width: 100%; display: flex; justify-content: space-between;(logo left, links right; sits above the page content, below the block overlays which come later in the DOM / cover it)..nav-links:display: flex;..logo, .nav-item:padding: 1.5em; font-weight: 400;.a:font-family: "PP Supply Mono"; text-transform: uppercase; text-decoration: none; color: #000; font-size: 13px; font-weight: 400;.h1:width: 80%; position: absolute; top: 47.5%; left: 50%; transform: translate(-50%, -50%); text-align: center; font-weight: 900; font-size: 10vw; text-transform: uppercase; line-height: 1; letter-spacing: -0.05em;— one huge centered uppercase word..blocks-container:position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: flex; flex-direction: column; pointer-events: none;— a fullscreen overlay that never blocks clicks. Both containers stack at the same spot..row:flex: 1; width: 100%; display: flex;— 10 equal-height rows filling the viewport..block:position: relative; flex: 1; background: #667067; margin: -0.25px;— 11 equal-width blocks per row. The negative-0.25pxmargin overlaps block edges by a hair so there are no seam gaps between blocks when they cover.- Rest / origin states (critical — these set the animation direction):
``css .transition-in .block { transform-origin: top; transform: scaleY(1); } /* reveal grid: starts covering, collapses UP */ .transition-out .block { transform-origin: bottom; transform: scaleY(0); } /* cover grid: starts hidden, grows UP from bottom */ ` The .transition-in grid scales from its top edge (shrinking up reveals the page from the bottom); the .transition-out` grid scales from its bottom edge (growing up covers the page from the bottom).
GSAP effect (the important part — be exhaustive)
1. The CustomEase — reproduce cubic-bezier(0.22, 1, 0.36, 1)
The original (a framer-motion ease: [0.22, 1, 0.36, 1], 1s) is reproduced exactly as a named GSAP CustomEase. Register it once and reuse for every tween:
const ease = CustomEase.create("pageTransition", "M0,0 C0.22,1 0.36,1 1,1");
This is a very snappy ease-out (fast overshoot-free launch, long soft settle) — do not substitute power/expo presets; use the literal bezier control points 0.22,1 → 0.36,1.
2. Grid construction + per-block random stagger
Constants and the grid builder:
const ROWS = 10;
const COLS = 11;
function buildBlocks(container) {
for (let r = 0; r < ROWS; r++) {
const row = document.createElement("div");
row.className = "row";
for (let c = 0; c < COLS; c++) {
const block = document.createElement("div");
block.className = "block";
row.appendChild(block);
}
container.appendChild(row);
}
}
Build both .transition-in and .transition-out (110 blocks each). Then collect inBlocks / outBlocks node lists, the heading (.container h1), and all .nav-links.
The stagger is a function-based per-element start delay (not a cumulative GSAP stagger). Each block gets its own offset computed from its row index plus a random jitter:
// Verbatim delay formula. rowIndex is the block's row (0 = top … 9 = bottom).
const calculateRandomBlockDelay = (rowIndex, totalRows) => {
const blockDelay = Math.random() * 0.5; // 0 … 0.5s random jitter, per block
const rowDelay = (totalRows - rowIndex - 1) * 0.05; // bottom row → 0, top row → 0.45s
return blockDelay + rowDelay;
};
// GSAP function-stagger: receives the flat index i; derive the row via floor(i / COLS).
const staggerDelay = (i) => calculateRandomBlockDelay(Math.floor(i / COLS), ROWS);
Pass stagger: staggerDelay directly (a function, so GSAP calls it per target and uses the return as that target's absolute start offset). Net effect: the bottom row starts first, the top row last (row 9 → +0, row 8 → +0.05, … row 0 → +0.45), with each individual block additionally jittered by 0–0.5s — so the curtain rises with a scattered, organic edge rather than a clean line.
3. Initial page-in (runs once on load)
On DOMContentLoaded, after building the grids, prime states and play the reveal grid once:
gsap.set(inBlocks, { scaleY: 1 }); // reveal grid fully covering
gsap.set(outBlocks, { scaleY: 0 }); // cover grid hidden
gsap.to(inBlocks, {
scaleY: 0, // 1 → 0: blocks collapse UP (origin top) and uncover the Index page
duration: 1,
ease, // the CustomEase "pageTransition"
stagger: staggerDelay, // per-block random-by-row offset
});
So on first paint the screen is fully covered by the sage grid, then the blocks shrink up in a scattered wave to reveal the initial "INDEX" heading.
4. The navigate(targetPage) transition (per click)
Guarded, two-phase, chained via onComplete callbacks:
let currentPage = "index";
let isAnimating = false;
const pageTitles = { index: "Index", about: "The Crew", contact: "Say Hello" };
function navigate(targetPage) {
if (isAnimating || targetPage === currentPage) return; // ignore mid-anim + no-op clicks
isAnimating = true;
// PHASE A — COVER: the transition-out grid grows up from the bottom to blanket the screen.
gsap.set(outBlocks, { scaleY: 0 });
gsap.to(outBlocks, {
scaleY: 1, // 0 → 1: blocks grow UP (origin bottom) → viewport fully covered
duration: 1,
ease,
stagger: staggerDelay,
onComplete: () => {
// Fully covered → swap the heading + route behind the curtain (never visible).
heading.textContent = pageTitles[targetPage];
currentPage = targetPage;
// Hand-off: reveal grid snaps to covering, cover grid snaps back to hidden.
gsap.set(inBlocks, { scaleY: 1 });
gsap.set(outBlocks, { scaleY: 0 });
// PHASE B — REVEAL: the transition-in grid shrinks up toward the top to uncover the new page.
gsap.to(inBlocks, {
scaleY: 0, // 1 → 0: blocks collapse UP (origin top) → new page revealed
duration: 1,
ease,
stagger: staggerDelay,
onComplete: () => { isAnimating = false; }, // release the guard
});
},
});
}
- Two grids, seamless hand-off: the
.transition-outgrid does the covering; at full coverage the heading swaps and the.transition-ingrid is instantly set toscaleY:1(covering) while.transition-outis reset toscaleY:0, so the reveal grid takes over with no flicker. Then.transition-inshrinks away. - Timing per click: cover (~1s + up to ~0.95s of scattered stagger tail) → swap → reveal (~1s + stagger tail). Each phase is a single
gsap.towith the sameduration:1,ease, and function-stagger; there are nodelayorpositionparameters beyond the per-block stagger. - Re-entrancy guard:
isAnimatingblocks clicks until the reveal'sonComplete; clicking the current page is a no-op.
5. Wiring
links.forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
navigate(link.dataset.page);
});
});
Every .nav-link (including the logo) routes through navigate using its data-page.
No ScrollTrigger, no SplitText, no lerp/rAF loop, no Three.js. The entire effect is gsap.set + gsap.to block tweens (scaleY) on the CustomEase("pageTransition") bezier, with a function-based per-row-random stagger, phased cover-then-reveal across two stacked fullscreen grids.
Assets / images
None. There are no image assets — the visual is pure CSS type plus the solid #667067 block grid. Do not add images.
Behavior notes
- Trigger: click on any nav link (or the logo) only. No scroll, no hover, no autoplay; the reveal plays once automatically on initial load.
- Both
.blocks-containeroverlays arepointer-events: none, so the grids never obstruct nav clicks even while covering. - The
-0.25pxblock margin is essential — without it, sub-pixel gaps between scaled blocks show the page through the curtain at full coverage. - No reduced-motion guard and no responsive media queries in the original;
font-size: 10vwalready scales the heading. The grid is always 10×11 regardless of viewport.