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.
Images
This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/advanced-infinite-scroll/bg.jpg
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--pine,--fog,--sage,--grass,--hair,--gutter,--display,--ui. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
Adapting this to React
Everything above describes a standalone module: it grabs .menu and the ten .menu-item rows with global selectors the instant the file is evaluated, drives them from a requestAnimationFrame loop that also lives at module scope, and tracks scroll position, drag state and velocity in plain top-level lets that are meant to exist exactly once for the life of the page. React withdraws every one of those guarantees, and the failure here is not a thrown error — the menu renders, wheels and drags normally for a moment, and then quietly runs at double gain or keeps recycling rows after you've routed away.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component is unusually exposed to that, because none of its state lives inside an instance a remount can throw away — currentScrollPosition, smoothScrollY, lastScrollY, isDragging, startY, currentY and the three measured heights are all bare variables. Treat the cleanup as part of the effect, not an afterthought: it has to stop the loop, remove every listener, and leave no scroll state behind for the next mount to inherit.
*(1) The entry point* — There is no DOMContentLoaded guard here at all; the script runs at the top level, the moment the module is evaluated. menuElement, menuItemElements, the measured heights, and the first call to adjustMenuItemsPosition(0) all execute during import, followed immediately by animate() and the block of addEventListener calls. In React that is before your component has rendered anything, so .menu and .menu-item don't exist yet and every one of those lookups returns null or an empty NodeList. Move the whole body — measurements, the initial layout call, animate, and every listener registration — into a useEffect with an empty dependency array. Leaving any of it at module scope means it runs once for the life of the page and every mount after the first reuses stale heights and a stale scroll position; leaving it in the component body means it re-measures and re-adds every listener on every render.
*(2) Element lookups* — document.querySelector(".menu") and document.querySelectorAll(".menu-item") both assume this component owns the document. Put a ref on the .menu stage element itself — the node the script calls menuElement — and read menuItemElements off that ref (rootRef.current.querySelectorAll(".menu-item")) instead of off document. This is not cosmetic here: during the StrictMode remount two copies of .menu exist for an instant, and an unscoped querySelectorAll can bind menuItemElements to the ten rows that are on their way out — after which the rAF loop spends its whole life positioning elements no longer attached to anything.
*(3) Cleanup* — Wrap the setup in a gsap.context scoped to the root ref, but this component needs one thing beyond the usual revert: the gsap.set call inside adjustMenuItemsPosition and the gsap.to that drives scale/rotate are not only called once during setup, they're re-issued from inside animate on every single frame, indefinitely, for as long as the loop runs. A plain gsap.context(fn, scope) only auto-tracks animations created synchronously while fn itself is executing; by the time a later animation frame calls back into animate, that synchronous window has long closed, so those per-frame tweens are invisible to ctx.revert(). Use the context argument GSAP passes into your setup function and route the recurring calls through self.add() so they register:
useEffect(() => {
const root = rootRef.current;
const items = root.querySelectorAll(".menu-item");
let currentScrollPosition = 0;
let lastScrollY = 0;
let smoothScrollY = 0;
let rafId;
const ctx = gsap.context((self) => {
adjustMenuItemsPosition(items, 0);
const animate = () => {
rafId = requestAnimationFrame(animate);
smoothScrollY = interpolate(smoothScrollY, currentScrollPosition, ONE_TENTH);
self.add(() => {
adjustMenuItemsPosition(items, smoothScrollY);
const scrollSpeed = smoothScrollY - lastScrollY;
lastScrollY = smoothScrollY;
gsap.to(items, { scale: squashFor(scrollSpeed), rotate: tiltFor(scrollSpeed) });
});
};
animate();
}, rootRef);
return () => {
cancelAnimationFrame(rafId);
ctx.revert();
};
}, []);
Cancel the loop before you revert, in that order: cancelAnimationFrame stops any new frame from calling back in, and only then does ctx.revert() need to clean up whatever the last tracked frame actually created — including the scale/rotate tween that's still catching up to rest when the unmount lands mid-flick, which revert() can now kill instead of leaving to finish out its own default timing against a root that's about to disappear.
Move currentScrollPosition, lastScrollY, smoothScrollY, isDragging, startY, currentY and the three measured heights (menuElementHeight, menuItemHeight, totalMenuHeight) from module-level let into variables declared inside this same effect. As written, those are initialized once when the file is first imported, not once per mount — so a StrictMode remount doesn't start the menu at rest, it inherits whatever smoothScrollY and drag flags the aborted first mount left behind, and if this menu is ever mounted twice on the same page, both instances read and write the same scroll position.
Finally, pair every listener with a matching removal in the same cleanup. onWheelScroll, onDragStart, onDragMove and onDragEnd are already named, so removeEventListener can reference them directly — but the resize handler and the selectstart handler are both inline anonymous arrows in the code above; give each a name so it can be removed too. This matters more than usual on this component because React does not recreate the .menu host node between the StrictMode unmount and remount — the same DOM element survives — so unremoved mousewheel/touchstart/mousedown/etc. listeners don't just leak, they double up on that surviving node. A single wheel flick then fires onWheelScroll twice per event, and the menu scrolls and drags at twice the intended gain: the concrete shape "doubled speed" takes here. The resize listener is worse if left on window, since window itself is never torn down — an un-removed subscription accumulates one extra copy per mount for the life of the tab, each one re-measuring the same three heights redundantly on every future resize.