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
.blockdivs (5 per row), written out; JS does not generate them. - Every nav link (and the logo) carries
data-route(the target route id) anddata-title(the text to write into the heroh1). The router comparesdata-routeagainst the current route; the logo and the "Home" link both point todata-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) onhtml, body. - Curtain block color:
#746df8(periwinkle purple) —.blockbackground-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 topointer-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 → 0makes the top half retract upward and the bottom half retract downward (curtain opens from the center line);scaleY: 0 → 1grows 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 tweenscaleY: 1 → 0— the curtain retracts to the center line and vanishes. duration: 1per 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 tweenscaleY: 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, sameease: "power4.inOut", same per-columneach: 0.1,from: "start",axis: "x". The only difference fromrevealTransitionis the grid is stated explicitly asgrid: [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 fromcurrentRoute, and no transition is already running. - Order: close curtain (
animateTransition) → swapheroTitle.textContentto the link'sdata-title(the content change is hidden behind full purple cover) → open curtain (revealTransition) → set blocksvisibility:hiddenand 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 heroh1text 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-timerevealTransition()parts it to unveil the hero. If you want to avoid a flash, ensure the reveal runs onDOMContentLoaded. - The curtain sits above the nav (
z-index:2vs1) but haspointer-events:none, so it never intercepts clicks even at full cover; theisTransitioningflag is what actually blocks re-entrant navigation. - Blocks are
visibility:hiddenbetween transitions so they never overlay the resting page. - No
prefers-reduced-motionbranch, 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.