# 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 plugin **`CustomEase`**. No ScrollTrigger, no SplitText, no Lenis/smooth-scroll, no Three.js.

```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).

```html
<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 text **`Ink_Octopus`**; the three nav links carry `data-page="index" | "about" | "contact"` with visible labels **Home / About / Contact**. All four are `.nav-link` (all are clickable route triggers).
- Both `.blocks-container` divs start **empty** — the JS injects 10 `.row` divs, each with 11 `.block` divs, into **each** container (so two full grids exist, one for covering, one for revealing).
- The `<h1>` initial text is `Index`; 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.

```css
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.25px` margin** 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:
```js
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:
```js
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-link`s.

**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:
```js
// 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:
```js
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:
```js
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-out` grid does the covering; at full coverage the heading swaps and the `.transition-in` grid is instantly set to `scaleY:1` (covering) while `.transition-out` is reset to `scaleY:0`, so the reveal grid takes over with no flicker. Then `.transition-in` shrinks 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.to` with the same `duration:1`, `ease`, and function-stagger; there are **no** `delay` or `position` parameters beyond the per-block stagger.
- **Re-entrancy guard:** `isAnimating` blocks clicks until the reveal's `onComplete`; clicking the current page is a no-op.

### 5. Wiring
```js
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-container` overlays are `pointer-events: none`, so the grids never obstruct nav clicks even while covering.
- The `-0.25px` block 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: 10vw` already scales the heading. The grid is always 10×11 regardless of viewport.
