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-gridstarts empty in the HTML — JS builds and appends all the block<div>s. - Each nav link carries a
data-routematching asection.page'sdata-route. Only onesection.pagehas classactiveat a time (the home page/on load). - Note both the logo
aand the first nav itemapoint todata-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 itsh1.- 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 inlineleft/top/width/height/transform-originfrom 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;andconst 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;andblockHeight = window.innerHeight / ROWS;— each cell exactly tiles the viewport.- Nested loop
row(0→3) ×col(0→15): creatediv.transition-blockand 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-gridand push into ablocks[]array. - Before generating, clear:
transitionGrid.innerHTML = ""and resetblocks = []. - After building,
gsap.set(blocks, { scaleX: 0 })— all blocks start collapsed (invisible, zero-width). - Call
createTransitionGrid()once on load, and rebuild it onwindow.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 alternatingtransform-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,animateIntotal ≈ ~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. Sameduration,ease,stagger,fromdirection per row, and"<"concurrency. Total ≈ ~0.975s.
Sequencing (navigate(route))
State: currentRoute = "/", isTransitioning = false.
- Guard: if
isTransitioningorroute === 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'sonComplete:showPage(route)(toggle.activeso the targetsection.pagebecomes the only visible page) andcurrentRoute = 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.pagesections,page.classList.toggle("active", page.dataset.route === route).- There is no hold/delay between the two phases —
animateOutfires immediately inanimateIn'sonComplete. 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-motionbranch 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.setre-collapsesscaleXto 0. .transition-gridhaspointer-events: none, so even at full cover the navbar underneath can't be clicked — but theisTransitioningguard 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.