# Hypnotic Image Gallery — GSAP Flip Layout Morph (3 layouts) + Lenis Scroll List

## Goal
Build a full-viewport image gallery of **14 tiles** that **morphs between three completely different layouts** when you click the numbered nav items (`01` / `02` / `03`). The star effect is **GSAP Flip**: on every switch it records the on-screen state of all 14 tiles, swaps a single layout class on the gallery, then animates every tile smoothly from its old position/size to its new one with a custom **"hop"** ease and a tiny per-tile stagger. Layout `01` is a scattered editorial grid, layout `02` is a small vertical column that becomes a **Lenis-smoothed vertical scroll list** (a framed "minimap" box and a hidden full-size preview column fade in and drift as you scroll), and layout `03` piles all 14 tiles into a single stacked deck in the top-right corner.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins **`Flip`**, **`CustomEase`**, and **`ScrollToPlugin`**, and **`lenis`** for smooth scroll. Imports:
```js
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { CustomEase } from "gsap/CustomEase";
import { ScrollToPlugin } from "gsap/ScrollToPlugin";
import Lenis from "lenis";

gsap.registerPlugin(Flip, CustomEase, ScrollToPlugin);
```
Start Lenis immediately with a self-driving rAF loop (do NOT tie it to gsap.ticker here — it runs its own loop):
```js
const lenis = new Lenis();
function raf(time) {
  lenis.raf(time);
  requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
```

## Layout / HTML
Class/id names are load-bearing — the JS and the Flip layouts query them.
```
<nav>
  <div class="nav-item"><p>Motionprompts</p></div>
  <div class="nav-item"><p id="layout-1-gallery">01</p></div>
  <div class="nav-item"><p id="layout-2-gallery">02</p></div>
  <div class="nav-item"><p id="layout-3-gallery">03</p></div>
  <div class="nav-item"><p>Menu</p></div>
</nav>

<div class="gallery-container">
  <div class="gallery layout-1-gallery">
    <div class="img" id="img1"><img src="…/img1.jpg" alt="" /></div>
    <div class="img" id="img2"><img src="…/img2.jpg" alt="" /></div>
    …
    <div class="img" id="img14"><img src="…/img14.jpg" alt="" /></div>
  </div>
</div>

<div class="minimap"></div>

<div class="img-previews">
  <img src="…/img1.jpg" alt="" />
  …
  <img src="…/img14.jpg" alt="" />   <!-- all 14, same order -->
</div>
```
- The gallery starts with **both** classes `gallery layout-1-gallery`. There are exactly **14** `.img` tiles, ids `img1`…`img14` in DOM order; each wraps one `<img>`.
- `.minimap` is an **empty** framed box (no content).
- `.img-previews` is a hidden column that repeats **all 14 images at full size** — it is invisible (`opacity:0`) but its tall absolute height is what makes the page scrollable in layout `02` (the scroll handler reads its `scrollHeight`). Keep the same 14 images in the same order.
- Nav labels: keep `Motionprompts`, `01`, `02`, `03`, `Menu` (neutral demo text — no real brand). Only the three numbered `<p>` carry ids and are clickable layout switches.

## Styling
- Font: **"PP Neue Montreal"** (import from `https://fonts.cdnfonts.com/css/pp-neue-montreal`); if unavailable, fall back to a clean grotesk `sans-serif`. `html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; }`.
- Global reset `* { margin:0; padding:0; box-sizing:border-box; }`. Colors are default black text on the page's default white background (no explicit palette). `img { width:100%; height:100%; object-fit:cover; }` — every tile is a hard cover-crop of its box.
- `nav`: `position:fixed; top:0; left:0; width:100%; padding:0.75em 2em; display:flex; z-index:2;`. Each `.nav-item { flex:1; }`. `.nav-item p { text-transform:uppercase; font-size:13px; font-weight:500; padding:1em 0.25em; cursor:pointer; }`.
- `.gallery-container`: `width:100%; height:100%; padding-top:4em;`.

**Layout 01 — scattered grid** (`.gallery.layout-1-gallery`): `position:relative; width:100%; height:100%; transform:translateX(0%);`. Tiles are `.gallery.layout-1-gallery .img { position:absolute; width:100px; height:125px; }` and placed by percentage rows (`top`) and columns (`left`/`right`):
- Rows: `top:0%` → img1–img4; `top:25%` → img5–img8; `top:50%` → img9,img10; `top:75%` → img11–img14.
- Columns: `left:2em` → img1,img5,img11; `left:15%` → img2; `left:25%` → img6; `left:45%` → img3,img9; `left:65%` → img4,img10,img12; `left:75%` → img13; `right:15%` → img7; `right:2em` → img8,img14.

**Layout 02 — vertical column** (`.gallery.layout-2-gallery`): `padding-top:0.5em; position:fixed; top:25%; left:10%; transform:translateX(0%);`. Its `.img` are `width:75px; height:100px; margin-bottom:1em;` with **no** absolute positioning, so they stack as a normal vertical column in DOM order.

**Layout 03 — stacked deck** (`.gallery.layout-3-gallery`): `position:relative; width:100%; height:100%; transform:translateX(0%);`. Its `.img` are `position:absolute; top:4em; right:4em; width:300px; height:400px;` — **all 14 share the same corner coordinates**, so they pile into one big overlapping stack in the top-right (later ids on top).

**Minimap** `.minimap`: `position:fixed; top:25%; left:12.5%; transform:translateX(-50%); width:140px; height:90px; border:1px solid #000; border-radius:2px; z-index:2; visibility:hidden; opacity:0;` — a small empty framed rectangle, hidden until layout `02`.

**Preview column** `.img-previews`: `position:absolute; top:25%; left:50%; transform:translateX(-50%); width:30%; opacity:0;`. `.img-previews img { width:600px; height:700px; padding:1em 0; }` — a tall stack of 14 big images, permanently invisible; its job is purely to give the document its scroll height.

Include the Lenis helper CSS (`.lenis.lenis-smooth { scroll-behavior:auto !important; }`, `.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior:contain; }`, `.lenis.lenis-stopped { overflow:hidden; }`, `.lenis.lenis-smooth iframe { pointer-events:none; }`).

## GSAP effect (be exhaustive)

### CustomEase "hop"
Register one custom ease used for every Flip transition:
```js
CustomEase.create("hop", "M0,0 C0.028,0.528 0.129,0.74 0.27,0.852 0.415,0.967 0.499,1 1,1");
```
This is a fast-out / long-settle curve — it rushes ~85% of the way early, then eases into the final position with a gentle tail (the "hypnotic" glide).

### State + refs
```js
const items            = document.querySelectorAll("nav .nav-item p");
const gallery          = document.querySelector(".gallery");
const galleryContainer = document.querySelector(".gallery-container");
const imgPreviews      = document.querySelector(".img-previews");
const minimap          = document.querySelector(".minimap");
let activeLayout = "layout-1-gallery";
```

### Click → switchLayout(newLayout)
Each of the three numbered `<p>` gets a `click` listener; if it has an `id`, call `switchLayout(item.id)`.
```js
function switchLayout(newLayout) {
  if (newLayout === activeLayout) return;
  // Special case: leaving layout 02 while scrolled down → smooth-scroll back to top first
  if (activeLayout === "layout-2-gallery" && window.scrollY > 0) {
    gsap.to(window, {
      scrollTo: { y: 0 },
      duration: 0.5,
      ease: "power3.out",
      onComplete: () => switchLayoutHandler(newLayout),
    });
  } else {
    switchLayoutHandler(newLayout);
  }
}
```
So if you're scrolled inside layout `02` and click `01`/`03`, it first uses **ScrollToPlugin** to glide the window back to `y:0` over **0.5s `power3.out`**, and only then runs the Flip (prevents morphing from a scrolled offset).

### The Flip morph — switchLayoutHandler(newLayout)
This is the core. Capture → swap class → Flip.from:
```js
function switchLayoutHandler(newLayout) {
  const state = Flip.getState(gallery.querySelectorAll(".img"));   // 1. record current rects of all 14 tiles

  gallery.classList.remove(activeLayout);                          // 2. swap the single layout class…
  gallery.classList.add(newLayout);                               //    …tiles jump to their new CSS positions/sizes

  let staggerValue = 0.025;                                        // 3. default per-tile stagger
  if (
    (activeLayout === "layout-1-gallery" && newLayout === "layout-2-gallery") ||
    (activeLayout === "layout-3-gallery" && newLayout === "layout-2-gallery")
  ) {
    staggerValue = 0;                                              //    collapsing INTO the column → no stagger (all at once)
  }

  Flip.from(state, {                                              // 4. animate old rects → new rects
    duration: 1.5,
    ease: "hop",
    stagger: staggerValue,
  });

  activeLayout = newLayout;
  …
}
```
Exact values:
- **`Flip.from` duration `1.5`s, `ease:"hop"`.** Flip interpolates each tile's position AND size (100×125 ↔ 75×100 ↔ 300×400) between layouts — tiles slide, scale, and re-pile in one continuous move.
- **`stagger`**: `0.025`s per tile in DOM order for most transitions, giving a rippling cascade; but it's forced to **`0`** (all tiles move simultaneously) specifically when going **01→02** or **03→02**, so collapsing into the vertical column snaps together rather than trailing.

### Layout-02 chrome fade + scroll wiring
Immediately after starting the Flip, toggle the extra UI depending on the target layout:
```js
if (newLayout === "layout-2-gallery") {
  gsap.to([imgPreviews, minimap], { autoAlpha: 1, duration: 0.3, delay: 0.5 });
  window.addEventListener("scroll", handleScroll);
} else {
  gsap.to([imgPreviews, minimap], { autoAlpha: 0, duration: 0.3 });
  window.removeEventListener("scroll", handleScroll);
  gsap.set(gallery, { clearProps: "y" });
  gsap.set(minimap, { clearProps: "y" });
}
// mark the active nav item (class only, no built-in style)
items.forEach((item) => item.classList.toggle("active", item.id === newLayout));
```
- Entering `02`: fade the minimap + preview column in via **`autoAlpha:1`** (opacity + visibility), **duration 0.3s, delay 0.5s** (so they appear as the Flip is settling), and attach the scroll handler.
- Leaving `02` (to `01` or `03`): **`autoAlpha:0`** over 0.3s, detach the scroll handler, and **`clearProps:"y"`** on both `gallery` and `minimap` to wipe any scroll-driven `y` transform so they return to their untranslated home.

### Scroll handler (layout 02 only) — parallax of gallery + minimap
While in layout `02`, `handleScroll` maps page scroll to a `y` translate on the fixed gallery column (so it scrolls even though it's `position:fixed`) and drifts the minimap down slightly:
```js
function handleScroll() {
  if (activeLayout !== "layout-2-gallery") return;

  const imgPreviewsHeight = imgPreviews.scrollHeight;   // tall hidden column = scrollable range
  const galleryHeight     = gallery.scrollHeight;
  const scrollY           = window.scrollY;
  const windowHeight      = window.innerHeight;

  const scrollFraction    = scrollY / (imgPreviewsHeight - windowHeight);
  const galleryTranslateY = -scrollFraction * (galleryHeight - windowHeight) * 1.525;
  const minimapTranslateY =  scrollFraction * (windowHeight - minimap.offsetHeight) * 0.425;

  gsap.to(gallery, { y: galleryTranslateY, ease: "none", duration: 0.1 });
  gsap.to(minimap, { y: minimapTranslateY, ease: "none", duration: 0.1 });
}
```
- `scrollFraction` is 0→1 across the scrollable range defined by the hidden preview column.
- The gallery column moves **up** proportionally, over-scrolled by the magic factor **`1.525`** so the small 75×100 tiles travel further than the page (fast list feel). The minimap drifts **down** by up to `(windowHeight − 90) × 0.425`.
- Both are `gsap.to` with `ease:"none"`, `duration:0.1` — a short smoothing tween each scroll event, riding on top of Lenis's already-smoothed scroll.

### On load
`window` `load` → if `activeLayout === "layout-2-gallery"` call `handleScroll()` once (no-op on the default `01`, but keeps state correct).

## Assets / images
**14 distinct images**, role = *gallery tiles* (`img1`…`img14`), reused verbatim (same files, same order) in the hidden full-size preview column. Because every `<img>` is `object-fit:cover`, any source aspect works, but they render in **portrait-ish boxes** (100×125, 75×100, 300×400), so portrait or square framing crops best. For a cohesive editorial feel, use a **varied set on dark/neutral backgrounds**: e.g. a moody macro photograph, a few abstract 3D renders (glossy forms, stacked discs), several dramatically-lit product still-lifes (a camera, a wristwatch, a VR headset, a portable speaker, a vintage radio), and a couple of interior/architecture detail shots. No brand logos or real client marks. Provide all 14 as separate files.

## Behavior notes
- Entirely **click + scroll driven** — no autoplay, no hover. The three numbered nav items are the only triggers; the Flip plays on each switch.
- The scroll listener is added only while in layout `02` and removed on exit; `clearProps:"y"` resets the parallax so re-entering `01`/`03` starts clean.
- The `.active` class toggled on nav items is a state hook only (no visual style is required unless you want to add one).
- Guard re-entry: `switchLayout` early-returns if the requested layout equals the active one. GSAP naturally overwrites in-flight tweens if the user clicks mid-morph.
- Layout `02`'s scrollability comes entirely from the invisible `.img-previews` column's height — keep it present and full-size even though `opacity:0`.
