All components

JS Page Transitions

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen curtain page transition: clicking a nav link runs a GSAP tween that scales a 2x5 grid of purple blocks up on the Y axis (power4.inOut, staggered per column) to cover the viewport, swaps the hero heading, then scales the blocks back down to reveal the incoming page. The same reveal also plays on initial load.

How it's built

Categorytransition
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

page-transition curtain blocks scaley stagger gsap power4 minimal

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

JS Page Transitions (scaleY split-curtain cover-and-reveal)

Goal

Build a small single-page site with an in-page router where navigating between "pages" plays a full-screen curtain made of a 2-row × 5-column grid of purple blocks. The star effect is a two-phase scaleY curtain: the top row of blocks grows down from the top edge while the bottom row grows up from the bottom edge — the two halves meet at the horizontal midline to cover the viewport, the hero heading is swapped underneath while covered, then the same blocks scale back to zero (top row retracting up, bottom row retracting down) to part the curtain and reveal the new page. Every phase sweeps column by column, left→right, with a per-column stagger. The reveal half also plays once on initial load, acting as an intro/preloader. Trigger is a click on the fixed top nav 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. All logic runs inside a DOMContentLoaded listener.

Layout / HTML

.transition                               (fixed full-viewport overlay; the curtain)
  .transition-row.row-1                    (top half)
    .block × 5                             (5 columns)
  .transition-row.row-2                    (bottom half)
    .block × 5                             (5 columns)

.app
  nav
    .logo
      a[data-route="index"][data-title="Index"]   "Motionprompts"   (wordmark; also routes home)
    .nav-items
      a[data-route="index"][data-title="Index"]     "Home"
      a[data-route="about"][data-title="About"]      "About"
      a[data-route="contact"][data-title="Contact"]  "Contact"
  .hero
    h1                                     "Index"   (the swappable heading; matches the initial route's title)
  • The curtain markup is static in the HTML — exactly 10 .block divs (5 per row), written out; JS does not generate them.
  • Every nav link (and the logo) carries data-route (the target route id) and data-title (the text to write into the hero h1). The router compares data-route against the current route; the logo and the "Home" link both point to data-route="index".
  • Use neutral / fictional labels only: wordmark "Motionprompts", nav Home / About / Contact, hero titles Index / About / Contact. No real brands.

Styling

Fonts (these are the original demo's licensed faces; if unavailable, fall back as noted — the giant 15vw hero size and the layout are what matter):

  • Body / nav: "Bagoss Standard TRIAL" (a clean geometric grotesque sans). Fallback: any neutral sans-serif.
  • Hero h1: "VTC Carrie" (a casual hand-lettered / marker display face). Fallback: a bold display/handwritten face.

Global reset: * { margin:0; padding:0; box-sizing:border-box; }.

Color palette:

  • Page background: #f3f3f0 (warm off-white / cream) on html, body.
  • Curtain block color: #746df8 (periwinkle purple) — .block background-color.
  • Text: #000 (links and hero).

Type & structure (load-bearing):

  • html, body { width:100%; height:100%; }.
  • a { text-decoration:none; color:#000; font-size:16px; font-weight:500; }.
  • nav: position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:1; (logo left, nav-items right).
  • .nav-items { display:flex; justify-content:center; align-items:center; gap:2em; }.
  • .hero: position:absolute; top:47.5%; left:50%; transform:translate(-50%,-50%); (dead-center, nudged slightly above middle).
  • .hero h1: font-family:"VTC Carrie"; font-size:15vw; line-height:90%; — one enormous word filling the viewport width.

Curtain CSS (the effect surface — reproduce exactly):

  • .transition: position:fixed; top:0; left:0; width:100vw; height:100vh; display:flex; flex-direction:column; z-index:2; pointer-events:none; — it stacks above the nav (nav is z-index 1, curtain z-index 2) yet never blocks clicks thanks to pointer-events:none.
  • .transition-row: flex:1; display:flex; — the two rows split the viewport height into equal top/bottom halves, and each row lays its 5 blocks out in a horizontal flex line.
  • .block: flex:1; background-color:#746df8; transform:scaleY(1); will-change:transform; visibility:visible; — each block is 1/5 of the row width, full row height, and starts fully expanded and visible (so on first paint the whole screen is a solid purple curtain before any JS runs).
  • Transform origins (this is what makes the two halves split):
  • .transition-row.row-1 .block { transform-origin: top; } — top-row blocks scale from their top edge.
  • .transition-row.row-2 .block { transform-origin: bottom; } — bottom-row blocks scale from their bottom edge.
  • So scaleY: 1 → 0 makes the top half retract upward and the bottom half retract downward (curtain opens from the center line); scaleY: 0 → 1 grows the top half down and the bottom half up until they meet (curtain closes).

GSAP effect (exhaustive)

Shared constants & state
  • const ease = "power4.inOut"; — used by both phases.
  • const heroTitle = document.querySelector(".hero h1");
  • let currentRoute = "index"; — matches the initial hero title "Index".
  • let isTransitioning = false; — re-entrancy guard.
On load

Run the reveal once, then hide the blocks:

revealTransition().then(() => {
  gsap.set(".block", { visibility: "hidden" });
});

Because the blocks are painted solid purple (scaleY:1, visible) before JS executes, this plays as an intro: the curtain parts to reveal the "Index" hero, then the blocks are set visibility:hidden so they don't sit invisibly over the page.

revealTransition() — part the curtain (open)

Returns a Promise resolved on the tween's onComplete:

function revealTransition() {
  return new Promise((resolve) => {
    gsap.set(".block", { scaleY: 1 });
    gsap.to(".block", {
      scaleY: 0,
      duration: 1,
      stagger: { each: 0.1, from: "start", grid: "auto", axis: "x" },
      ease: ease,
      onComplete: resolve,
    });
  });
}
  • First force all 10 blocks to scaleY:1 (fully covering), then tween scaleY: 1 → 0 — the curtain retracts to the center line and vanishes.
  • duration: 1 per block, ease: "power4.inOut".
  • stagger: { each: 0.1, from: "start", grid: "auto", axis: "x" } — GSAP auto-detects the block layout as a 2×5 grid; axis: "x" means the stagger distance is computed only from each block's column (its x position), so the two blocks sharing a column (one in row-1, one in row-2) animate in unison, and columns fire left→right at 0.1s apart. Column 0 at t=0, col 1 at 0.1, … col 4 at 0.4. Total wall time ≈ 0.4 + 1 = 1.4s.
animateTransition() — draw the curtain (close)

Returns a Promise resolved on onComplete:

function animateTransition() {
  return new Promise((resolve) => {
    gsap.set(".block", { visibility: "visible", scaleY: 0 });
    gsap.to(".block", {
      scaleY: 1,
      duration: 1,
      stagger: { each: 0.1, from: "start", grid: [2, 5], axis: "x" },
      ease: ease,
      onComplete: resolve,
    });
  });
}
  • First make the blocks visible and collapsed (visibility:visible; scaleY:0), then tween scaleY: 0 → 1 — the top half grows down and the bottom half grows up until they meet and fully cover the viewport (solid purple).
  • Same duration: 1, same ease: "power4.inOut", same per-column each: 0.1, from: "start", axis: "x". The only difference from revealTransition is the grid is stated explicitly as grid: [2, 5] (2 rows, 5 columns) instead of "auto" — behaviorally identical (both stagger by column left→right). Total ≈ 1.4s.
Sequencing (nav-click router)

Wire every <a> on the page:

document.querySelectorAll("a").forEach((link) => {
  link.addEventListener("click", (event) => {
    event.preventDefault();
    const route = link.dataset.route;
    const title = link.dataset.title;
    if (route && route !== currentRoute && !isTransitioning) {
      isTransitioning = true;
      animateTransition()
        .then(() => {
          currentRoute = route;
          heroTitle.textContent = title;   // swap the hero heading WHILE fully covered
          return revealTransition();
        })
        .then(() => {
          gsap.set(".block", { visibility: "hidden" });
          isTransitioning = false;
        });
    }
  });
});
  • Guard: ignore the click unless there's a route, it differs from currentRoute, and no transition is already running.
  • Order: close curtain (animateTransition) → swap heroTitle.textContent to the link's data-title (the content change is hidden behind full purple cover) → open curtain (revealTransition) → set blocks visibility:hidden and release the guard.
  • There is no hold/delay between close and open — the reveal fires immediately in the cover's onComplete. Door-to-door ≈ ~2.8s (1.4s close + 1.4s open). This is a purely visual in-page router: no History API, no URL change, no separate page loads — only the hero h1 text changes.

Assets / images

None. This component uses no images, icons, canvas, or video — just solid-colored CSS blocks, nav text, and one giant hero word. No logos or brand marks.

Behavior notes

  • On first paint the screen is a solid purple curtain (blocks default to scaleY:1, visible in CSS); the load-time revealTransition() parts it to unveil the hero. If you want to avoid a flash, ensure the reveal runs on DOMContentLoaded.
  • The curtain sits above the nav (z-index:2 vs 1) but has pointer-events:none, so it never intercepts clicks even at full cover; the isTransitioning flag is what actually blocks re-entrant navigation.
  • Blocks are visibility:hidden between transitions so they never overlay the resting page.
  • No prefers-reduced-motion branch, no min-width gate — the effect is identical on desktop and mobile (blocks are flex-sized, so the 2×5 grid always fits the viewport). No infinite loops; each transition is one-shot per click.