All components

Infinite Scroll with Parallax

JavaScript animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

An endlessly looping vertical project list where wheel and touch scrolling drive a lerp-smoothed translateY on recycled full-screen slides. Each slide's image gets an independent parallax offset, and when scrolling stops the list eases (cubic ease-out) to snap the nearest slide fully into view.

How it's built

Categoryscroll
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safeyes

infinite-scroll parallax snap-scroll lerp requestanimationframe vanilla-js editorial minimal gallery

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

Infinite Vertical Project List — Lerp-Smoothed Scroll, Recycled Slides, Per-Image Parallax, Cubic Snap

Goal

Build a full-viewport, endlessly looping vertical project gallery. Each "slide" is a 100vw × 100vh split screen — one half is a project title (name + catalog number), the other half is a full-bleed image — and the halves alternate sides down the list. Wheel and touch scrolling drive a translateY on every slide through a lerp inside a requestAnimationFrame loop, so nothing jumps: the whole column glides and settles with inertia-like smoothness. Three details sell it: (1) the list is infinite in both directions — slides are procedurally created around the viewport and recycled out of a Map, so you can scroll forever; (2) each slide's image gets an independent parallax offset (it drifts against the scroll at a 0.2 ratio, with its own secondary lerp); and (3) when scrolling stops, the list eases with a cubic ease-out to snap the nearest slide perfectly into the frame.

Tech

Vanilla HTML/CSS/JS with ES module imports. No GSAP and no animation libraries are needed — the entire engine is a hand-rolled requestAnimationFrame loop with linear interpolation (lerp) plus a manual cubic-ease snap. No smooth-scroll library either: the page itself never scrolls (body { overflow: hidden }); the wheel is hijacked to drive the slides. Everything runs on plain DOM style.transform writes.

Layout / HTML

The static HTML is almost empty — the JS builds every slide by cloning a hidden template.

.container
  ul.project-list
    .project.template  (style="display:none" — the clone source)
      .side
        .title
          h1  "Euphoria"
          h1  "01"
      .side
        .img > img (src="" alt="")

script type="module" src="./script.js"

The template holds both halves in the title-left / image-right order; the JS rewrites each clone's innerHTML to place the image on the correct side per slide (see below). Key classes the engine depends on: .container, .project-list, .project, .template, .side, .img, .title.

Put the slide data inline in script.js as an array of 6 objects. isAlternate decides which half the image lands in (false = title-left/image-right, true = image-left/title-right); it alternates down the list:

const projectData = [
  { title: "Euphoria",      image: "./images/img1.jpeg", isAlternate: false },
  { title: "Scratcher",     image: "./images/img2.jpeg", isAlternate: true  },
  { title: "Ember",         image: "./images/img3.jpeg", isAlternate: false },
  { title: "Liquid Soleil", image: "./images/img4.jpeg", isAlternate: true  },
  { title: "Vacuum",        image: "./images/img5.jpeg", isAlternate: false },
  { title: "Synthesis",     image: "./images/img6.jpeg", isAlternate: true  },
];

Styling

Font — the CSS sets html, body { font-family: "Lay Grotesk"; }, but no @font-face / web-font is loaded, so it simply falls back to the platform default sans-serif. Use any clean grotesk (or the system default); the type look is fully defined by the h1 rules below, not by that family name.

Global reset / page* { margin:0; padding:0; box-sizing:border-box }. html, body { width:100%; height:100%; overflow:hidden }. Background is the default white; text default black. No color palette beyond that — the images supply all the color.

Structure CSS (load-bearing — the transforms only work with this positioning):

  • .container: position: fixed; width: 100%; height: 100vh; — the fixed stage.
  • .project-list: position: absolute; width: 100%; list-style: none; will-change: transform;.
  • .project: width: 100vw; height: 100vh; display: flex; overflow: hidden; position: absolute; left: 0; will-change: transform;every slide is absolutely stacked at the top-left; its translateY is the only thing that positions it vertically. display:flex splits it into the two halves.
  • .side: flex: 1; height: 100%; overflow: hidden; — the two equal halves.
  • .img: width: 100%; height: 100%; overflow: hidden; — the clipping mask around the image.
  • img: position: relative; width: 100%; height: 100%; object-fit: cover; will-change: transform; transform: translateY(0) scale(1.5); — the scale(1.5) zoom is the parallax headroom; the JS overwrites this transform every frame (always re-appending scale(1.5)).
  • .title: display: flex; justify-content: space-between; align-items: center; — pushes the name to one edge and the number to the other within its half.
  • h1: text-transform: uppercase; font-size: 2.5rem; font-weight: 500; letter-spacing: -0.0125rem; padding: 0.5em; — big uppercase grotesk; the 0.5em padding is what separates the name from the number.

The animation engine (exhaustive — this IS the effect)

No GSAP. Everything below is one requestAnimationFrame loop plus event handlers mutating a shared state object, and a Map-based slide recycler.

Helpers, config, state
const lerp = (start, end, factor) => start + (end - start) * factor;

const config = {
  SCROLL_SPEED: 0.75,     // wheel deltaY multiplier
  LERP_FACTOR: 0.05,      // main list smoothing per frame
  BUFFER_SIZE: 15,        // slides kept alive on each side of the current index
  CLEANUP_THRESHOLD: 50,  // remove slides farther than this (in indices) from current
  MAX_VELOCITY: 120,      // per-wheel-event clamp
  SNAP_DURATION: 500,     // ms of the snap ease
};

const state = {
  currentY: 0, targetY: 0,      // the lerp pair driving the whole column
  lastY: 0,                     // targetY captured at touch start
  scrollVelocity: 0,
  isDragging: false, startY: 0,
  projects: new Map(),          // index -> .project element
  parallaxImages: new Map(),    // index -> per-image parallax controller
  projectHeight: window.innerHeight,
  isSnapping: false, snapStartTime: 0, snapStartY: 0, snapTargetY: 0,
  lastScrollTime: Date.now(),
  isScrolling: false,
};
Slide indexing and the infinite data wrap

Slides are addressed by a signed integer index (…, −2, −1, 0, 1, 2, …). The data array (length 6) is wrapped with a negative-safe modulo so any index maps to a real project:

const wrap = (i) => ((Math.abs(i) % projectData.length) + projectData.length) % projectData.length;
const getProjectData = (index) => projectData[wrap(index)];

The displayed catalog number is (wrap(index) + 1) padded to 2 digits → "01""06", repeating every 6 slides.

Creating a slide (createProjectElement(index))
  1. If state.projects.has(index), bail (idempotent).
  2. Clone the .template, set its display:flex, remove the template class.
  3. Rewrite innerHTML with the two .side halves. If data.isAlternateimage half first, title half second; else → title half first, image half second. Title half is <div class="side"><div class="title"><h1>${title}</h1><h1>${number}</h1></div></div>; image half is <div class="side"><div class="img"><img src="${image}" alt="${title}" /></div></div>.
  4. Position it: project.style.transform = translateY(${index * state.projectHeight}px) (its base slot before scroll offset).
  5. Append to .project-list, store in state.projects, and create + store its parallax controller (below) in state.parallaxImages.
Per-image parallax controller (createParallaxImage(imgEl))

Closure over one <img>; captures bounds once at creation (right after the base transform is set, so bounds.top ≈ index * projectHeight):

updateBounds() → rect = imgEl.getBoundingClientRect();
                 bounds = { top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY };
                 // window.scrollY is always 0 (page never scrolls)

update(scroll) {                             // called every frame with state.currentY
  const relativeScroll = -scroll - bounds.top;      // = -(slide's current on-screen Y)
  targetTranslateY = relativeScroll * 0.2;          // parallax ratio 0.2
  currentTranslateY = lerp(currentTranslateY, targetTranslateY, 0.1);  // secondary lerp 0.1
  if (Math.abs(currentTranslateY - targetTranslateY) > 0.01)
    imgEl.style.transform = `translateY(${currentTranslateY}px) scale(1.5)`;
}

Net effect: an image whose slide sits at on-screen Y = y targets translateY = -0.2 * y. A slide above the viewport (negative y) pushes its image down; a slide below (positive y) pulls it up; a slide perfectly framed (y ≈ 0) targets 0. The extra scale(1.5) gives the room to move without exposing edges, and the 0.2 ratio + independent 0.1 lerp make each image drift slightly slower and laggier than the slide it lives in.

Buffering & recycling (checkAndCreateProjects, run every frame)
  • currentIndex = Math.round(-state.targetY / state.projectHeight).
  • Ensure every index in [currentIndex − BUFFER_SIZE, currentIndex + BUFFER_SIZE] (±15) exists → creates them as needed.
  • Cleanup: any slide whose index is more than CLEANUP_THRESHOLD (50) away from currentIndex is .remove()d and deleted from both maps. This keeps the DOM bounded (~31 live slides in steady state) while the loop stays infinite.
  • On load, createInitialProjects() seeds indices −15 … +15.
Snap logic (cubic ease-out, manual)
  • getClosestSnapPoint()-Math.round(-state.targetY / state.projectHeight) * state.projectHeight (the nearest whole-slide offset).
  • initiateSnap()isSnapping = true; snapStartTime = Date.now(); snapStartY = targetY; snapTargetY = getClosestSnapPoint();.
  • updateSnap() per frame:

``js const progress = Math.min((Date.now() - snapStartTime) / config.SNAP_DURATION, 1); // 0→1 over 500ms const t = 1 - Math.pow(1 - progress, 3); // cubic ease-out state.targetY = snapStartY + (snapTargetY - snapStartY) * t; if (progress >= 1) { state.isSnapping = false; state.targetY = snapTargetY; } ` Note the snap animates targetY (not currentY); currentY` then lerps toward it, so the snap is doubly softened.

The rAF loop (animate) — order matters
function animate() {
  const timeSinceLastScroll = Date.now() - state.lastScrollTime;

  // 1. Auto-snap once input has been idle > 100ms and we're not already snapping/dragging
  if (!state.isSnapping && !state.isDragging && timeSinceLastScroll > 100) {
    const snapPoint = getClosestSnapPoint();
    if (Math.abs(state.targetY - snapPoint) > 1) initiateSnap();
  }

  // 2. Advance the snap ease if active (writes targetY)
  if (state.isSnapping) updateSnap();

  // 3. Main lerp — ONLY when not dragging: currentY chases targetY at factor 0.05
  if (!state.isDragging) state.currentY += (state.targetY - state.currentY) * config.LERP_FACTOR;

  // 4. Grow/recycle the buffer around the current index
  checkAndCreateProjects();

  // 5. Position every live slide and update its image parallax
  state.projects.forEach((project, index) => {
    const y = index * state.projectHeight + state.currentY;
    project.style.transform = `translateY(${y}px)`;
    state.parallaxImages.get(index)?.update(state.currentY);
  });

  requestAnimationFrame(animate);
}
  • The main lerp factor is 0.05 — this exponential chase is the entire "smoothness" of the column; every input only ever nudges targetY.
  • While dragging, currentY is intentionally frozen (step 3 is skipped): the visible column holds during a touch drag and then eases to catch up to targetY the moment the finger lifts.
Input handlers
  • Wheel (window, { passive: false }): e.preventDefault(); cancel any snap (isSnapping = false); stamp lastScrollTime = Date.now(); then

``js const scrollDelta = e.deltaY * config.SCROLL_SPEED; // ×0.75 state.targetY -= Math.max(Math.min(scrollDelta, config.MAX_VELOCITY), -config.MAX_VELOCITY); // clamp ±120 per event ` Scroll down (positive deltaY) decreases targetY` → the column moves up. The ±120 clamp caps how far a single wheel tick can throw it.

  • Touch drag (window):
  • touchstartisDragging = true; isSnapping = false; startY = e.touches[0].clientY; lastY = state.targetY; lastScrollTime = Date.now();.
  • touchmove → if not dragging, return; else absolute delta from the start point: deltaY = (e.touches[0].clientY - startY) * 1.5 (drag multiplier 1.5×), state.targetY = lastY + deltaY; and re-stamp lastScrollTime.
  • touchendisDragging = false (which re-enables the main lerp so the column catches up, then auto-snaps after 100ms idle).
  • Resize (window): state.projectHeight = window.innerHeight; re-base every slide's transform to translateY(index * projectHeight) and call each image's updateBounds() (the next frame re-applies currentY and re-parallaxes).
Boot

Wrap init in DOMContentLoaded: attach the listeners, createInitialProjects(), then start animate().

Assets / images

6 images, one per slide, each shown in a half-viewport-wide × full-viewport-tall column (so a tall portrait frame, roughly 1:2 at desktop widths) via object-fit: cover zoomed scale(1.5), so most portrait-ish sources work. They should read as one moody, editorial, cinematic portrait series — dark, high-contrast, art-directed. By role/order:

  1. Low-key studio portrait — sharp black bowl-cut hair, black turtleneck, a long pearl earring, soft blue-grey backdrop ("Euphoria" / 01).
  2. Portrait with eyes closed inside a large sculptural sheer orange fabric headpiece billowing in warm light on cream ("Scratcher" / 02).
  3. Figure fully draped in flowing teal/sea-green fabric, seated on a rocky desert outcrop under a clear blue sky ("Ember" / 03).
  4. Face wrapped in horizontal bands of sheer black fabric — near-monochrome, very dark and high-contrast ("Liquid Soleil" / 04).
  5. Profile silhouette, short bob + round glasses, lit entirely in saturated red ("Vacuum" / 05).
  6. Extreme close-up of a face emerging from glossy, liquid-like black-chrome material with bright reflections ("Synthesis" / 06).

No logos, no brand marks — all titles ("Euphoria", "Scratcher", "Ember", "Liquid Soleil", "Vacuum", "Synthesis") are fictional project names. Because the data wraps every 6 slides, the same 6 images/titles repeat endlessly as you scroll.

Behavior notes

  • Truly infinite both directions — the recycler always keeps ±15 slides live around the current index and prunes beyond ±50, so the DOM stays small while scrolling never hits an end.
  • Snap-to-slide kicks in ~100ms after the last input and eases (cubic ease-out over 500ms) so a whole slide always ends up perfectly framed; any new wheel/touch input cancels an in-progress snap.
  • Per-image parallax is subtle and continuous (ratio 0.2, own lerp 0.1) — images drift within their frames as slides travel, always keeping the scale(1.5) zoom.
  • The page never scrolls; only the slides' transforms move (.container is position: fixed, body is overflow: hidden). Wheel is preventDefaulted.
  • Touch is fully supported (1.5× drag); note the deliberate quirk that the column visually freezes *during* a drag and catches up on release.
  • No reduced-motion guard in the original — the rAF loop runs continuously. Everything degrades to a static first screen if JS is off (only the hidden template exists in the static HTML).