# Advanced Infinite Scroll — Endless Wrapping Editorial Menu with Velocity Squash & Tilt

## Goal
Build a **fullscreen, edge-to-edge vertical menu** of large editorial rows (a small category label + a huge serif title per row) that **loops endlessly in both directions**. You drive it by **mouse wheel** or by **click-and-drag** (and touch). A `requestAnimationFrame` loop **lerps** the scroll position for buttery inertia, and every row is repositioned each frame with `gsap.set` using a `y` **modifier** that runs `gsap.utils.wrap` so items that leave one edge seamlessly re-enter from the other — a true infinite recycle with only 10 DOM nodes. On top of that, the whole list **elastically scales down and tilts** proportional to the current scroll **velocity**: flick it fast and the rows shrink and rotate; let it settle and they spring back to `scale 1`, `rotate 0`. A dark, vignetted photo sits fixed behind everything.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap`** (npm) — **core only**. No plugins (no ScrollTrigger, no Draggable, no SplitText), no smooth-scroll library. The infinite wrap and the inertia are done by hand with `gsap.set` + `gsap.utils.wrap` + a manual `requestAnimationFrame` lerp loop.
```js
import gsap from "gsap";
```

## Layout / HTML
Class names are load-bearing — the JS and CSS query them.
```
.menu                              (fullscreen stage; the drag + wheel surface; cursor: grab)
  .menu-img                        (fixed full-bleed background image layer)
    img                            (the dark photo)
  ul.menu-wrapper                  (the list; list-style: none)
    li.menu-item                   (one row — repeated 10×; absolutely positioned, moved by JS)
      .item-category > p           (small uppercase label, e.g. "Cinema")
      .item-name > p               (huge serif title, e.g. "La Strada Nascosta")
```
Use exactly **10** `.menu-item` rows with neutral, fictional category/title pairs (no real brands):
1. Cinema — La Strada Nascosta
2. Advertising — Echoes in Motion
3. Videoclip — Hyperspace
4. Cinema — Onda Silenziosa
5. Media — Nexus
6. Workshop — Between Lines
7. Media Kit — The Enigma
8. Cinema — Le Stelle Cadenti
9. Videoclip — Quantum Pulse
10. Advertising — Neon Flow

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box; user-select:none; }`.

`html, body { width:100vw; height:100vh; background:#000; color:#fff; overflow:hidden; }` — the page never scrolls natively; all motion is JS-driven inside the fixed viewport.

`img { width:100%; height:100%; object-fit:cover; }`.

**Background layer**
- `.menu-img { position:absolute; top:0; left:0; width:100%; height:100%; }` — full-bleed photo.
- `.menu-img::after` — a **radial vignette** overlay on top of the photo that darkens toward the edges and goes pure black at the corners:
  ```css
  .menu-img::after {
    content:""; position:absolute; inset:0; z-index:1;
    background: radial-gradient(circle,
      rgba(0,0,0,0)    0%,
      rgba(0,0,0,0.75) 50%,
      rgba(0,0,0,1)    100%);
  }
  ```

**Stage / rows**
- `.menu { width:100%; height:100%; overflow:hidden; cursor:grab; }` and `.menu.is-dragging { cursor:grabbing; }` (JS toggles the class while dragging).
- `.menu-wrapper { list-style:none; }`.
- `.menu-item { position:absolute; top:0; left:0; width:100%; padding:4em 0; display:flex; gap:2em; }` — every row is stacked at the same absolute origin; JS gives each its own `y`. `padding:4em 0` (≈64px top+bottom at the default 16px root) sets the row rhythm and therefore the measured row height.
- `.item-category { flex:2; display:flex; justify-content:flex-end; align-items:flex-end; }` — right-aligned, bottom-aligned label column.
- `.item-name { flex:4; display:flex; align-items:flex-end; }` — bottom-aligned title column, twice as wide as the category column.

**Typography** (both faces are stylized display fonts; substitute close web equivalents and keep the *sizes/roles*):
- `.item-category p { font-family:"Dharma Gothic M", "Oswald", sans-serif; font-size:40px; text-transform:uppercase; }` — a **tall, condensed uppercase grotesque** for the small category tag.
- `.item-name p { font-family:"RL-Unno Test", "Playfair Display", serif; font-size:120px; line-height:90%; }` — a **large elegant serif** for the title; the `line-height:90%` (=108px) is what dominates the row height.

## GSAP effect (be exhaustive)

There is **no ScrollTrigger and no plugin** — the whole effect is (A) a hand-rolled inertial scroll value, (B) an infinite wrap that recycles rows, and (C) a velocity-reactive squash/tilt tween. Wire it as follows.

### 0. Refs & measured constants
```js
const menuElement       = document.querySelector(".menu");
const menuItemElements  = document.querySelectorAll(".menu-item");
let menuElementHeight   = menuElement.clientHeight;          // viewport height
let menuItemHeight      = menuItemElements[0].clientHeight;  // one row's live pixel height
let totalMenuHeight     = menuItemElements.length * menuItemHeight; // 10 rows tall
```
Scroll state:
```js
let currentScrollPosition = 0;  // the target (updated by wheel/drag)
let lastScrollY           = 0;  // previous frame's smoothed value (for velocity)
let smoothScrollY         = 0;  // the eased/lerped value actually rendered
```
Linear-interpolation helper (classic lerp):
```js
const interpolate = (start, end, factor) => start * (1 - factor) + end * factor;
```

### 1. Position + infinite wrap (called every frame)
```js
const adjustMenuItemsPosition = (scroll) => {
  gsap.set(menuItemElements, {
    y: (index) => index * menuItemHeight + scroll,
    modifiers: {
      y: (y) => {
        const wrappedY = gsap.utils.wrap(
          -menuItemHeight,                 // min
          totalMenuHeight - menuItemHeight, // max (exclusive)
          parseInt(y)
        );
        return `${wrappedY}px`;
      },
    },
  });
};
adjustMenuItemsPosition(0);  // initial layout
```
- Each row's **base y** is `index * menuItemHeight + scroll` — so at `scroll = 0` they stack 0, 1×H, 2×H … 9×H down the page.
- The **`modifiers.y`** function wraps that value into the half-open range `[-menuItemHeight, totalMenuHeight - menuItemHeight)` — a span of exactly `totalMenuHeight` (10 rows). A row pushed past the bottom edge instantly reappears one row above the top (and vice-versa), which is what makes the loop seamless and gap-free. `parseInt(y)` strips the unit before wrapping; the return is a px string.
- Because every row is `position:absolute; top:0` and gets its own transform `y`, this single `gsap.set` (with function-based `y`) lays out and recycles the entire list on each call.

### 2. Input → target scroll position
**Wheel** (note: bound to the legacy `"mousewheel"` event) accumulates the *negative* deltaY:
```js
const onWheelScroll = (event) => { currentScrollPosition -= event.deltaY; };
```
**Click/touch drag** with a **3× multiplier** so a short drag travels far:
```js
let startY = 0, currentY = 0, isDragging = false;

const onDragStart = (e) => {
  startY = e.clientY || e.touches[0].clientY;
  isDragging = true;
  menuElement.classList.add("is-dragging");   // cursor → grabbing
};
const onDragMove = (e) => {
  if (!isDragging) return;
  currentY = e.clientY || e.touches[0].clientY;
  currentScrollPosition += (currentY - startY) * 3;   // 3× drag gain
  startY = currentY;
};
const onDragEnd = () => {
  isDragging = false;
  menuElement.classList.remove("is-dragging");
};
```

### 3. The rAF loop — inertia + velocity squash/tilt (the star)
```js
const animate = () => {
  requestAnimationFrame(animate);

  // (A) ease the rendered scroll toward the target — inertia. Lerp factor 0.1.
  smoothScrollY = interpolate(smoothScrollY, currentScrollPosition, 0.1);
  adjustMenuItemsPosition(smoothScrollY);

  // (B) instantaneous velocity = how far the smoothed value moved this frame
  const scrollSpeed = smoothScrollY - lastScrollY;
  lastScrollY = smoothScrollY;

  // (C) drive the whole list's scale + rotation from that velocity
  gsap.to(menuItemElements, {
    scale: 1 - Math.min(100, Math.abs(scrollSpeed)) * 0.0075,
    rotate: scrollSpeed * 0.2,
  });
};
animate();
```
Exact semantics — reproduce these numbers precisely:
- **Inertia:** `smoothScrollY` chases `currentScrollPosition` with **lerp factor `0.1`** every frame — a soft, trailing catch-up that keeps gliding after you stop.
- **Velocity:** `scrollSpeed` is the signed per-frame delta of the *smoothed* position, so it grows while accelerating and decays to 0 as the lerp settles.
- **Scale (squash):** `scale = 1 - Math.min(100, |scrollSpeed|) * 0.0075`. Speed is clamped at 100, so scale bottoms out at `1 - 100*0.0075 = 0.25` on a hard flick and returns to `1.0` at rest. Applied to **all rows uniformly**.
- **Rotate (tilt):** `rotate = scrollSpeed * 0.2` **degrees** — signed, so scrolling up tilts one way and down the other; 0° at rest.
- **The tween itself:** this is a plain `gsap.to` with **no explicit `duration` or `ease`**, so it uses GSAP's defaults (**`duration: 0.5`, `ease: "power1.out"`**). It is re-issued **every frame**, and GSAP's overwrite means each new tween retargets the in-flight scale/rotate — the net result is a smooth, slightly *elastic* lag where the squash/tilt trails the motion and springs back. Do **not** add a duration; the every-frame re-tween is exactly what produces the elastic feel.
- Note the layering: `gsap.set` writes each row's `y` (per-item) every frame while `gsap.to` tweens `scale`/`rotate` (shared) — GSAP composes all three onto the same transform, so position, squash, and tilt coexist.

### 4. Listeners & resize
```js
menuElement.addEventListener("mousewheel", onWheelScroll);
menuElement.addEventListener("touchstart", onDragStart);
menuElement.addEventListener("touchmove",  onDragMove);
menuElement.addEventListener("touchend",   onDragEnd);
menuElement.addEventListener("mousedown",  onDragStart);
menuElement.addEventListener("mousemove",  onDragMove);
menuElement.addEventListener("mouseleave", onDragEnd);
menuElement.addEventListener("mouseup",    onDragEnd);
menuElement.addEventListener("selectstart", () => false);   // block text selection while dragging

window.addEventListener("resize", () => {
  menuElementHeight = menuElement.clientHeight;
  menuItemHeight    = menuItemElements[0].clientHeight;
  totalMenuHeight   = menuItemElements.length * menuItemHeight;
});
```

## Assets / images
**1 image**, role = *fixed full-bleed background behind the scrolling menu*. A single **dark, moody, cinematic photograph** (any aspect — it is `object-fit:cover` over the full viewport; landscape works best). It is heavily dimmed by the CSS radial vignette so only the center peeks through and the edges go black; a low-key, desaturated frame (a dim interior, a night scene, atmospheric fog) reads best behind the white type. No logos, no brands.

## Behavior notes
- **Infinite in both directions** — wheel and drag can run forever; `gsap.utils.wrap` recycles the same 10 rows with no seam and no accumulating DOM.
- **Two inputs, one target:** wheel adds `-deltaY`, drag adds `(Δpointer)*3`, both into `currentScrollPosition`; the rAF lerp is the single source of what's rendered.
- **Cursor feedback:** `grab` at rest, `grabbing` (`.is-dragging`) while pressed; `selectstart → false` and `user-select:none` prevent text-selection artifacts during drag.
- **Reduced motion / mobile:** the effect is light (transforms only, ~10 nodes) and touch-enabled out of the box; no ScrollTrigger, canvas, or WebGL.
- On **resize**, re-measure `menuItemHeight`/`totalMenuHeight` so the wrap range stays correct.
