All components

Grid Shutter Page Transition

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Page transition where a fixed 4x16 grid of cream blocks scales in horizontally with a per-block stagger, even rows sweeping from the left and odd rows from the right, shuttering the viewport closed before the page swaps and the same animation reverses to reveal the new page. Triggered by clicking the top navbar links routing between three full-viewport hero pages.

How it's built

Categorytransition
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

page-transition grid shutter scalex stagger rows fake-router

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Grid Shutter Page Transition (scaleX row-shutter cover-and-reveal)

Goal

Build a client-side "fake router" for a small three-page site where navigating between pages plays a full-screen 4×16 grid of cream blocks that scale open horizontally to shutter the viewport closed, swaps the page content underneath while covered, then scales the same blocks back down to reveal the new page. The star effect is the two-phase scaleX shutter: a fixed grid of blocks whose rows animate concurrently but with alternating sweep direction — even rows grow anchored to the left edge sweeping left→right, odd rows grow anchored to the right edge sweeping right→left — each row carrying its own per-block stagger. Trigger is a click on the fixed top navbar links.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no SplitText, no Lenis. There is no scroll interaction at all. Import as:

import gsap from "gsap";

No gsap.registerPlugin call is needed.

Layout / HTML

.transition-grid                          (fixed full-viewport overlay; JS fills it with blocks — starts EMPTY)

nav.navbar                                (fixed top row, logo left / links right)
  .navbar-logo
    .navbar-item > a[data-route="/"]      "Duskfield"   (wordmark; also routes home)
  .navbar-items
    .navbar-item > a[data-route="/"]        "Genesis"
    .navbar-item > a[data-route="/cascade"] "Cascade"
    .navbar-item > a[data-route="/orbit"]   "Orbit"

section.hero.genesis.page.active[data-route="/"]      > h1 "Genesis"   (visible on load)
section.hero.cascade.page[data-route="/cascade"]      > h1 "Cascade"
section.hero.orbit.page[data-route="/orbit"]          > h1 "Orbit"
  • The .transition-grid starts empty in the HTML — JS builds and appends all the block <div>s.
  • Each nav link carries a data-route matching a section.page's data-route. Only one section.page has class active at a time (the home page / on load).
  • Note both the logo a and the first nav item a point to data-route="/"; both are wired the same way (clicking either navigates home).
  • Use neutral fictional names (fictional studio wordmark "Duskfield"; three cosmic page labels Genesis / Cascade / Orbit). No real brands.

Styling

Fonts (Google Fonts):

  • Display: Instrument Serif (regular + italic), used for the giant h1.
  • UI: Instrument Sans (weights 400–700 + italics), used for nav links.

Color tokens (:root):

  • --bg: #0f0f0f (near-black page background, seen only briefly under a hero image)
  • --fg: #f2f0e6 (warm cream — text color)
  • --transition-grid: #f2f0e6 (the same cream — the shutter block color)

Type:

  • h1: font-family:"Instrument Serif", serif; font-size: clamp(5rem, 15vw, 20rem); font-weight:500; letter-spacing:-3%; line-height:1;
  • nav a: text-decoration:none; color: var(--fg); font-family:"Instrument Sans", sans-serif; font-weight:500; letter-spacing:-2%;

Key structural CSS (load-bearing):

  • .navbar: position: fixed; width:100%; padding:1rem; display:flex; justify-content:space-between; align-items:flex-start; z-index:2;
  • .navbar-items: display:flex; gap: clamp(1rem, 4vw, 2rem);
  • .navbar-item: padding:1.5rem; (large hit area).
  • .hero: position:relative; width:100%; height:100svh; background-color:var(--bg); color:var(--fg); display:flex; justify-content:center; align-items:center; overflow:hidden; — each hero is a full-viewport centered stage for its h1.
  • Each hero gets a full-bleed CSS background image (not an <img> tag): background: url(<img>) no-repeat 50% 50%; background-size: cover; — one per page (genesis / cascade / orbit).
  • Fake-router visibility: .page { display:none; } and .page.active { display:flex; } — only the active page is in the layout; the others are removed entirely.
  • .transition-grid: position:fixed; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:100; overflow:hidden; — sits above everything (navbar z-index 2, grid z-index 100) and never blocks clicks.
  • .transition-block: position:absolute; background-color: var(--transition-grid); will-change: transform; — every block is absolutely positioned via inline left/top/width/height/transform-origin from JS.

Responsive (@media (max-width: 1000px)): .navbar padding grows to 2rem; .navbar-items becomes a right-aligned vertical column (align-items:flex-end; flex-direction:column; gap:0;) and .navbar-item padding shrinks to 0.25rem. (Purely nav layout; the shutter effect is unchanged.)

GSAP effect (exhaustive)

Constants & grid generation
  • const ROWS = 4; and const COLS = 16; — a fixed 4-row × 16-column grid (64 blocks total), independent of viewport size (blocks stretch to fit).
  • createTransitionGrid() builds the shutter:
  • blockWidth = window.innerWidth / COLS; and blockHeight = window.innerHeight / ROWS; — each cell exactly tiles the viewport.
  • Nested loop row (0→3) × col (0→15): create div.transition-block and set inline styles:
  • width = blockWidth + 1px, height = blockHeight + 1px — the +1px overlap hides sub-pixel seams between blocks when covered.
  • left = col * blockWidth, top = row * blockHeight.
  • transform-origin: ${row % 2 === 0 ? "left" : "right"} center;even rows (0, 2) anchor to their LEFT edge; odd rows (1, 3) anchor to their RIGHT edge. This is what makes rows grow from opposite sides.
  • Append each block to .transition-grid and push into a blocks[] array.
  • Before generating, clear: transitionGrid.innerHTML = "" and reset blocks = [].
  • After building, gsap.set(blocks, { scaleX: 0 }) — all blocks start collapsed (invisible, zero-width).
  • Call createTransitionGrid() once on load, and rebuild it on window.resize (window.addEventListener("resize", createTransitionGrid)).
  • Helper: getRowBlocks(row) = blocks.slice(row * COLS, row * COLS + COLS) — returns the 16 blocks of one row.
The two transition timelines (this is the whole effect)

Two functions, each building a gsap.timeline({ onComplete }) and adding one .to() per row, all positioned at "<" so every row starts at the same time (time 0) yet keeps its own internal stagger.

animateIn(onComplete) — shutter CLOSED (cover the screen):

const tl = gsap.timeline({ onComplete });
[0, 1, 2, 3].forEach((row) => {
  const rowBlocks = getRowBlocks(row);
  tl.to(rowBlocks, {
    scaleX: 1,
    duration: 0.6,
    ease: "power3.inOut",
    stagger: { each: 0.025, from: row % 2 === 0 ? "start" : "end" },
  }, "<");
});
return tl;
  • Every block animates scaleX: 0 → 1.
  • Per-block duration: 0.6, ease: "power3.inOut".
  • Per-row stagger: { each: 0.025, from: <start|end> }0.025s between consecutive blocks in the row. from: "start" on even rows makes the reveal ripple left→right; from: "end" on odd rows ripples right→left. Combined with the alternating transform-origin, adjacent rows sweep in opposite directions (an interleaved shutter).
  • All four .to() calls use position param "<" (start of the previous tween) so the rows animate concurrently, not sequentially.
  • Per-row wall time ≈ 0.025 × 15 + 0.6 ≈ 0.975s; because rows overlap, animateIn total ≈ ~0.975s.

animateOut(onComplete) — shutter OPEN (reveal the new page):

const tl = gsap.timeline({ onComplete });
[0, 1, 2, 3].forEach((row) => {
  const rowBlocks = getRowBlocks(row);
  tl.to(rowBlocks, {
    scaleX: 0,
    duration: 0.6,
    ease: "power3.inOut",
    stagger: { each: 0.025, from: row % 2 === 0 ? "start" : "end" },
  }, "<");
});
return tl;
  • Identical structure but scaleX: 1 → 0 — the blocks collapse back to zero width, uncovering the (already-swapped) page. Same duration, ease, stagger, from direction per row, and "<" concurrency. Total ≈ ~0.975s.
Sequencing (navigate(route))

State: currentRoute = "/", isTransitioning = false.

  • Guard: if isTransitioning or route === currentRoute, return (ignore re-clicks and clicks on the current page).
  • Set isTransitioning = true, then:
  • animateIn(() => { … }) — blocks scale IN to fully cover the viewport.
  • In animateIn's onComplete: showPage(route) (toggle .active so the target section.page becomes the only visible page) and currentRoute = route. The swap happens while the screen is fully covered by the cream shutter.
  • Then call animateOut(() => { isTransitioning = false; }) — blocks scale OUT to reveal the new page, and re-entrancy unlocks on complete.
  • showPage(route): loop all .page sections, page.classList.toggle("active", page.dataset.route === route).
  • There is no hold/delay between the two phases — animateOut fires immediately in animateIn's onComplete. Total door-to-door ≈ ~1.95s.
Trigger wiring

For every .navbar a (this includes the logo link and all three nav links), addEventListener("click", e => { e.preventDefault(); navigate(link.dataset.route); }). No History API / URL change — it is a purely visual fake router.

Assets / images

3 images, one full-bleed CSS background per hero page (background-size: cover, centered), all 16:9 / landscape to fill the viewport behind each giant serif h1. Role: atmospheric cosmic backdrops, each with a distinct palette so the swap is obvious the instant the shutter opens:

  • Genesis — a tall dark monolith/slab standing edge-on and glowing, wreathed in billowing amber-orange smoke and drifting embers against a near-black starfield; dominant colors are fiery orange against deep black.
  • Cascade — a wide, painterly nebula cloudscape of warm rust-orange clouds meeting cool teal-blue, with a scatter of small tan and grey planetary spheres and a dense field of stars; dominant colors are orange and blue.
  • Orbit — a single large pale ice-blue ringed gas planet, encircled by a thin dusty orange ring, floating against a starry deep-blue nebula; dominant colors are soft blue with an orange ring accent.

Provide 3 distinct landscape images in that order.

Behavior notes

  • Desktop and mobile both work; there is no prefers-reduced-motion branch and no min-width gate — only the navbar layout changes below 1000px.
  • The grid is a fixed 4×16 regardless of viewport; on resize it is fully rebuilt so cell dimensions re-fit the new size, and gsap.set re-collapses scaleX to 0.
  • .transition-grid has pointer-events: none, so even at full cover the navbar underneath can't be clicked — but the isTransitioning guard already blocks input during a transition.
  • No infinite loops; each transition is one-shot per click.
  • The home page (/ Genesis) is visible on load with no intro animation; the shutter only fires on nav clicks.