All components

Infinite Parallax Scroll with Minimap

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

Open live demo ↗ Raw prompt (.md)

What it does

Infinite vertical scroll through fullscreen project images with a synchronized centered minimap that mirrors the scroll at miniature scale. A hand-rolled rAF loop lerps a virtual scroll target driven by wheel and touch drag, recycles DOM slides around the current index for endless looping, applies per-image parallax (0.2x translateY on scale(1.5) images), and snaps to the nearest project with a 500ms cubic ease-out after 100ms of idle.

How it's built

Categoryscroll
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safeyes

infinite parallax minimap wheel touch lerp snap virtualized vanilla-js 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

Build: Infinite Parallax Scroll with Synchronized Minimap

Goal

A full-screen, infinitely looping vertical scroll through editorial project images, with a centered floating minimap that mirrors the exact same scroll at miniature scale. The star effect: a hand-rolled requestAnimationFrame engine that lerps a virtual scroll position driven by wheel + touch, recycles a small window of DOM slides for endless looping, applies per-image parallax, and magnetically snaps to the nearest project after the user stops scrolling. The big slides and the tiny minimap move in perfect proportional lockstep.

Tech

Vanilla HTML/CSS/JS with ES module imports (Vite/npm project). No GSAP, no ScrollTrigger, no Lenis, no animation library at all — the entire motion system is a single custom requestAnimationFrame loop with a manual lerp() helper and custom wheel/touch listeners. Everything is driven by imperative element.style.transform writes. Desktop uses wheel; mobile uses touch drag.

Layout / HTML

Minimal shell — JS generates all repeating content dynamically:

<div class="container">
  <ul class="project-list"></ul>          <!-- full-screen slides injected here -->
  <div class="minimap">
    <div class="minimap-wrapper">
      <div class="minimap-img-preview"></div>   <!-- mini image filmstrip -->
      <div class="minimap-info-list"></div>      <!-- text info stack -->
    </div>
  </div>
</div>
<script type="module" src="./script.js"></script>

The JS builds three parallel virtualized lists, all keyed by the same integer index:

  • .project (appended to .project-list): <div class="project"><img src=... alt=... /></div> — one full-viewport slide.
  • .minimap-img-item (appended to .minimap-img-preview): <div class="minimap-img-item"><img .../></div> — the same image, miniature.
  • .minimap-item-info (appended to .minimap-info-list): two rows of text:

``html <div class="minimap-item-info-row"><p>{NN}</p><p>{title}</p></div> <div class="minimap-item-info-row"><p>{category}</p><p>{year}</p></div> ` where {NN} is the 1-based project number zero-padded to 2 digits (0105`).

Styling

  • Font: Inter (Google Fonts, variable weight 100–900), applied to body.
  • p: text-transform: uppercase; font-size: 0.85rem; font-weight: 600; letter-spacing: -0.0125rem;
  • img (global): width:100%; height:100%; object-fit:cover;
  • .container: position:fixed; width:100%; height:100svh; overflow:hidden; (whole thing is a fixed, non-scrolling viewport — native page scroll never happens).
  • .project: position:absolute; width:100%; height:100svh; overflow:hidden; will-change:transform; — stacked absolutely at top:0, moved only via translateY.
  • Images inside slides and minimap items (.project img, .minimap-img-item img): position:relative; transform:scale(1.5); will-change:transform; — the 1.5× overscale gives the parallax room to move without exposing edges.
  • .minimap: position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); width:75%; height:calc(250px + 3rem); background:#fff; padding:1.5rem; overflow:hidden; — a white horizontal panel floating dead-center over the slides.
  • .minimap-wrapper: position:relative; width:100%; height:100%;
  • .minimap-img-preview: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:35%; height:100%; overflow:hidden; — a narrow centered window showing the miniature image filmstrip.
  • .minimap-img-item: position:absolute; width:100%; height:100%; overflow:hidden; will-change:transform;
  • .minimap-info-list: position:relative; width:100%; height:100%; overflow:hidden; — full-width text layer behind/around the center image window.
  • .minimap-item-info: position:absolute; width:100%; height:250px; display:flex; flex-direction:column; justify-content:space-between; will-change:transform;
  • .minimap-item-info-row: width:100%; display:flex; justify-content:space-between; padding:0.5rem; — number left / title right on the top row, category left / year right on the bottom row.
  • Responsive @media (max-width:1000px): .minimap-img-preview { left:unset; right:0; transform:translate(0,-50%); } (image window pins to the right edge) and .minimap-item-info-row { flex-direction:column; }.

The Effect (be exhaustive — this is the whole point)

There is no library. Reproduce this engine exactly.

Data & config

Five demo projects, each { title, image, category, year }. Use neutral fictional titles/categories (e.g. "Redroom Gesture 14" / "Concept Series" / "2025", "Shadowwear 6AM" / "Photography" / "2024", "Blur Formation 03" / "Kinetic Study" / "2024", "Sunglass Operator" / "Editorial Motion" / "2023", "Azure Figure 5" / "Visual Research" / "2024"). No real brand names.

const config = {
  SCROLL_SPEED: 0.75,   // wheel delta multiplier
  LERP_FACTOR: 0.05,    // main scroll smoothing per frame
  BUFFER_SIZE: 5,       // slides kept alive on each side of current
  MAX_VELOCITY: 150,    // clamp per-wheel-event movement
  SNAP_DURATION: 500,   // ms snap animation length
};

State holds currentY (rendered scroll, px), targetY (desired scroll, px), isDragging, isSnapping, three Maps (projects, minimap, minimapInfo) keyed by integer index, projectHeight = window.innerHeight, minimapHeight = 250, a snapStart {time,y,target}, and lastScrollTime.

const lerp = (a, b, t) => a + (b - a) * t;

Index → data wrapping (enables infinite loop)

getProjectData(index) maps any integer (including large negatives) into the 0–4 range via double-modulo: ((|i| % n) + n) % n. The 2-digit label is that wrapped index +1, padStart(2,"0").

Virtualization / recycling (syncElements, runs every frame)
  • current = Math.round(-targetY / projectHeight) = index nearest to center.
  • Keep window [current - BUFFER_SIZE, current + BUFFER_SIZE] (11 slides).
  • For each index in the window, createElement(i) for all three lists only if the map doesn't already have that index (idempotent).
  • After creating, iterate each map and el.remove() + map.delete(index) for any index outside the window. This constant create/destroy is what makes the scroll infinite with a tiny DOM.
  • On load, pre-seed indices -BUFFER_SIZE … BUFFER_SIZE.
Positioning (updatePositions, every frame)

Minimap scroll is the main scroll scaled by the height ratio:

minimapY = currentY * minimapHeight / projectHeight;   // = currentY * 250 / innerHeight
  • Slides: each .project at index i → translateY(i * projectHeight + currentY)px. Then its image parallax updates with (currentY, i).
  • Minimap images: each .minimap-img-item at index i → translateY(i * minimapHeight + minimapY)px. Image parallax updates with (minimapY, i).
  • Minimap info: each .minimap-item-info at index i → translateY(i * minimapHeight + minimapY)px (no parallax on text).
Per-image parallax (createParallax, closure per image)

Each image owns a private current = 0. On every update(scroll, index):

target  = (-scroll - index * height) * 0.2;   // 0.2× depth factor
current = lerp(current, target, 0.1);          // its own 0.1 smoothing
if (Math.abs(current - target) > 0.01)
  img.style.transform = `translateY(${current}px) scale(1.5)`;

So the image drifts inside its overscaled (1.5×) frame at 20% of the container's motion, with a secondary independent lerp — a layered, buttery parallax. The > 0.01 guard skips redundant writes when settled.

Magnetic snap (cubic ease-out)

In the main loop, when not snapping, not dragging, and now - lastScrollTime > 100 ms (100 ms idle after last input):

  • Compute snapPoint = -Math.round(-targetY / projectHeight) * projectHeight.
  • If |targetY - snapPoint| > 1, call snapToProject(): set isSnapping = true, record snapStart.time = now, snapStart.y = targetY, snapStart.target = snapPoint.

updateSnap() (each frame while snapping):

progress = min((now - snapStart.time) / SNAP_DURATION, 1);   // 0→1 over 500ms
eased    = 1 - Math.pow(1 - progress, 3);                     // cubic ease-out
targetY  = snapStart.y + (snapStart.target - snapStart.y) * eased;
if (progress >= 1) isSnapping = false;
Main loop order (animate, recursive requestAnimationFrame)
  1. Check idle → maybe snapToProject().
  2. If snapping → updateSnap() (drives targetY).
  3. If not dragging → currentY += (targetY - currentY) * LERP_FACTOR (0.05 smoothing toward target). While dragging, currentY is not lerped (touch sets targetY directly and it renders 1:1).
  4. syncElements() then updatePositions().
  5. requestAnimationFrame(animate).
Input handling
  • Wheel ({ passive:false }): e.preventDefault(), isSnapping = false, lastScrollTime = now. delta = clamp(e.deltaY * SCROLL_SPEED, -MAX_VELOCITY, +MAX_VELOCITY), then targetY -= delta. (Scrolling down decreases targetY, advancing forward.)
  • Touchstart: isDragging = true, isSnapping = false, store dragStart = { y: touch.clientY, scrollY: targetY }, lastScrollTime = now.
  • Touchmove: targetY = dragStart.scrollY + (touch.clientY - dragStart.y) * 1.5 (1.5× drag sensitivity), lastScrollTime = now.
  • Touchend: isDragging = false (loop then lerps + snaps).

Assets / images

Five full-bleed editorial photographs, landscape aspect (roughly 3:2, one closer to 5:4; the wide frames are cropped by object-fit:cover at 1.5× overscale so exact ratio is flexible). Each appears both as a full-screen slide and as a miniature in the minimap. Consistent look: moody, high-contrast, each image built around a single dominant color:

  1. Black silhouette of a person from the chest up with one open hand raised toward the viewer, flat saturated red backdrop.
  2. Silhouetted head-and-shoulders profile against an X of crossing red-orange light beams on a near-black background; deep reds and warm oranges.
  3. Motion-blurred cluster of young people in near-darkness, streaked with warm amber/brown skin tones over black — long-exposure blur, no sharp faces.
  4. Tight editorial crop of a figure's torso and bent arm wearing a multicolor abstract painterly-print garment (blues, ochres, reds, greens), set against a vivid solid orange background.
  5. Rear-view backlit silhouette of a person raising both hands to their head, against a vivid cobalt-blue background with a thin rim of light around the arms.

Behavior notes

  • Infinite in both directions: you can scroll up into negative indices forever; the modulo wrap + recycling means the same 5 projects cycle endlessly with correct numbering.
  • Everything is fixed-position; the page itself never scrolls — wheel is preventDefaulted and consumed by the virtual scroll.
  • Snap only fires after 100 ms of idle and only when off-center by more than 1px, so it feels like a gentle magnetic settle, never fighting active scrolling.
  • Two independent smoothing stages (main LERP_FACTOR 0.05 + per-image 0.1) plus the 0.2 parallax factor are what produce the layered, weighty feel — keep all three exact.
  • No reduced-motion branch in the original; motion is continuous.