All components

Advanced Infinite Scroll

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen vertical menu of category/title rows that loops endlessly: a requestAnimationFrame loop lerps the scroll position and calls gsap.set with a y modifier using gsap.utils.wrap to seamlessly recycle items, while gsap.to elastically scales down and rotates the rows proportional to scroll velocity. Driven by mouse wheel and click/touch drag.

How it's built

Categorymenu
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

infinite-scroll loop drag wheel velocity gsap-utils-wrap editorial menu raf

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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):

  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
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 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:

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: 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
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.