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.
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):
- Cinema — La Strada Nascosta
- Advertising — Echoes in Motion
- Videoclip — Hyperspace
- Cinema — Onda Silenziosa
- Media — Nexus
- Workshop — Between Lines
- Media Kit — The Enigma
- Cinema — Le Stelle Cadenti
- Videoclip — Quantum Pulse
- 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 owny.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; theline-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
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:
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):
const interpolate = (start, end, factor) => start * (1 - factor) + end * factor;
1. Position + infinite wrap (called every frame)
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 atscroll = 0they stack 0, 1×H, 2×H … 9×H down the page. - The
modifiers.yfunction wraps that value into the half-open range[-menuItemHeight, totalMenuHeight - menuItemHeight)— a span of exactlytotalMenuHeight(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:0and gets its own transformy, this singlegsap.set(with function-basedy) 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:
const onWheelScroll = (event) => { currentScrollPosition -= event.deltaY; };
Click/touch drag with a 3× multiplier so a short drag travels far:
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)
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:
smoothScrollYchasescurrentScrollPositionwith lerp factor0.1every frame — a soft, trailing catch-up that keeps gliding after you stop. - Velocity:
scrollSpeedis 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 at1 - 100*0.0075 = 0.25on a hard flick and returns to1.0at rest. Applied to all rows uniformly. - Rotate (tilt):
rotate = scrollSpeed * 0.2degrees — signed, so scrolling up tilts one way and down the other; 0° at rest. - The tween itself: this is a plain
gsap.towith no explicitdurationorease, 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.setwrites each row'sy(per-item) every frame whilegsap.totweensscale/rotate(shared) — GSAP composes all three onto the same transform, so position, squash, and tilt coexist.
4. Listeners & resize
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.wraprecycles the same 10 rows with no seam and no accumulating DOM. - Two inputs, one target: wheel adds
-deltaY, drag adds(Δpointer)*3, both intocurrentScrollPosition; the rAF lerp is the single source of what's rendered. - Cursor feedback:
grabat rest,grabbing(.is-dragging) while pressed;selectstart → falseanduser-select:noneprevent 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/totalMenuHeightso the wrap range stays correct.