All components

ThreeJS Infinite Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

Infinite vertical slider built entirely in Three.js (no GSAP): ten textured planes loop endlessly inside a WebGL scene, driven by mouse wheel, drag and touch with lerped smoothing and momentum friction. Scroll velocity bends each plane's vertices along Z with a sine falloff, creating a directional curvature distortion, while an HTML title and counter track the slide closest to center.

How it's built

Categoryslider
Techthree
Complexitysection
Performance costheavy
Mobile-safeyes

three.js webgl slider infinite-loop vertical wheel drag touch vertex-distortion momentum lerp

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

ThreeJS Infinite Vertical Slider (WebGL, velocity vertex-bend, no GSAP)

Goal

Build a full-screen infinite vertical slider rendered entirely in Three.js — no DOM images, no animation library. Ten textured planes of slightly random heights are stacked head-to-toe into one tall column inside a WebGL scene and loop endlessly as you scroll. Input comes from the mouse wheel, click-drag, and touch swipe; a hand-rolled requestAnimationFrame engine lerps a virtual scroll position, adds momentum with friction on release, and wraps the column so slides recycle forever in both directions. The star effect: scroll velocity bends each plane's vertices along Z — a sine-shaped bulge that peaks at screen center and is signed by scroll direction (curves one way scrolling down, the other way scrolling up) and scaled by how fast/decelerating you are, so the images ripple into a soft curved sheet while moving and flatten back out when still. Two small HTML text nodes — a slide title and a zero-padded counter — always track the slide currently closest to center.

Tech

  • Vanilla HTML / CSS / JS with ES module imports, bundled by Vite.
  • three (npm) only, imported import * as THREE from "three";.
  • No GSAP, no ScrollTrigger, no Lenis, no shaders. The mesh geometry is deformed on the CPU by

writing vertex Z positions every frame; all motion is a single custom requestAnimationFrame loop with manual lerp smoothing and momentum. Uses MeshBasicMaterial (unlit) — no lights needed.

  • Desktop uses wheel + mouse drag; mobile uses touch. Everything is a single script.js.

Data model

Ten slide objects, each { name, img }. Use neutral, fictional one/two-word titles (no real brands), e.g.:

const slides = [
  { name: "Contour",        img: "/path/img1.jpg"  },
  { name: "Velum Drift",    img: "/path/img2.jpg"  },
  { name: "Quiet Exchange", img: "/path/img3.jpg"  },
  { name: "Earth Routine",  img: "/path/img4.jpg"  },
  { name: "Metal Echo",     img: "/path/img5.jpg"  },
  { name: "Tanned Edge",    img: "/path/img6.jpg"  },
  { name: "Humidity",       img: "/path/img7.jpg"  },
  { name: "Limestone Air",  img: "/path/img8.jpg"  },
  { name: "Warm Surface",   img: "/path/img9.jpg"  },
  { name: "Dust & Craft",   img: "/path/img10.jpg" },
];

Layout / HTML

Almost no DOM — just the WebGL <canvas> and a two-item text overlay. The <p> nodes start empty; JS fills them.

<section class="slider">
  <div class="slide-info">
    <p id="slide-title"></p>
    <p id="slide-count"></p>
  </div>
  <canvas></canvas>
</section>
<script type="module" src="./script.js"></script>

document.querySelector("canvas") is passed straight into the renderer; p#slide-title and p#slide-count are cached for the active-slide readout.

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box; user-select:none; }

  • Font: p { font-family: "PP Neue Montreal", sans-serif; font-weight: 500; } — a clean neutral

grotesque sans (use any Neue-Montreal-style face or a system sans fallback). All text weight 500. Default browser font size (no size override on the <p>s — they render at ~16px).

  • sectionposition: relative; width: 100%; height: 100svh; overflow: hidden;
  • canvasposition: fixed; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden;

(the WebGL surface fills the whole viewport behind the text).

  • .slide-info — `position: absolute; top: 50%; left: 0; transform: translateY(-50%);

width: 100%; padding: 0 2rem; display: flex; justify-content: space-between; color: #fff; z-index: 2;` — a vertically centered row spanning the full width: title pinned left, counter pinned right, both white, floating over the canvas.

  • The scene's own background is a near-black #141414 set on the Three.js scene (below), so the

page reads as white text on a very dark WebGL field.

The star effect — Three.js infinite column + velocity vertex-bend (be exact)

This is a near-verbatim port. Reproduce the constants, the stacking math, the distortion function, the input math, and the per-frame loop exactly.

Renderer, scene & camera
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x141414);            // near-black field

const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;                                    // looking down the -Z axis at the column
Config constants (use these exact values)
const config = {
  minHeight: 1,               // slide plane min height (world units)
  maxHeight: 1.5,             // slide plane max height
  aspectRatio: 1.5,           // plane width = height * 1.5 (landscape planes)
  gap: 0.05,                  // vertical gap between stacked slides
  smoothing: 0.05,            // scrollPosition -> scrollTarget lerp per frame
  distortionStrength: 2.5,    // Z bulge multiplier
  distortionSmoothing: 0.1,   // distortionAmount lerp per frame
  momentumFriction: 0.95,     // momentum decay per frame
  momentumThreshold: 0.001,   // below this momentum snaps to 0
  wheelSpeed: 0.01,           // wheel delta -> scroll units
  wheelMax: 150,              // per-wheel-event delta clamp
  dragSpeed: 0.01,            // mouse-drag px -> scroll units
  dragMomentum: 0.01,         // mouse-drag release -> momentum
  touchSpeed: 0.01,           // touch-move px -> scroll units
  touchMomentum: 0.1,         // touch release -> momentum
};

Helpers used throughout:

const wrap = (value, range) => ((value % range) + range) % range;   // always-positive modulo
const zeroPad = (n) => String(n).padStart(2, "0");
Random heights + head-to-toe stacking (defines the loop length)

Each slide gets a random height in [minHeight, maxHeight], then they are stacked into one column; slideOffsets[i] is the center position of slide i along the vertical axis:

const totalSlides = slides.length;                                   // 10
const slideHeights = Array.from({ length: totalSlides },
  () => config.minHeight + Math.random() * (config.maxHeight - config.minHeight));

const slideOffsets = [];
let stackPosition = 0;
for (let i = 0; i < totalSlides; i++) {
  if (i === 0) {
    slideOffsets.push(0);
    stackPosition = slideHeights[0] / 2;
  } else {
    stackPosition += config.gap + slideHeights[i] / 2;   // walk to this slide's center
    slideOffsets.push(stackPosition);
    stackPosition += slideHeights[i] / 2;                // walk to its far edge
  }
}
const loopLength = stackPosition + config.gap + slideHeights[0] / 2;  // full cycle length
const halfLoop = loopLength / 2;
Meshes + texture cover-fit

One PlaneGeometry per slide, subdivided 32 × 16 (width × height segments — enough vertices for a smooth CPU bend). MeshBasicMaterial, double-sided, starts flat grey 0x999999 until its texture loads:

for (let i = 0; i < totalSlides; i++) {
  const height = slideHeights[i];
  const width  = height * config.aspectRatio;
  const geometry = new THREE.PlaneGeometry(width, height, 32, 16);
  const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, color: 0x999999 });
  const mesh = new THREE.Mesh(geometry, material);

  mesh.userData = {
    originalVertices: [...geometry.attributes.position.array],   // pristine copy for distortion
    offset: slideOffsets[i],
    name: slides[i].name,
    index: i,
  };

  textureLoader.load(slides[i].img, (texture) => {
    texture.colorSpace = THREE.SRGBColorSpace;
    material.map = texture;
    material.color.set(0xffffff);          // reveal texture at full brightness
    material.needsUpdate = true;
    // fit image aspect by shrinking the mesh in one axis (letterbox-contain, keeps no stretch)
    const imageAspect = texture.image.width / texture.image.height;
    const planeAspect = width / height;    // = config.aspectRatio
    const ratio = imageAspect / planeAspect;
    if (ratio > 1) mesh.scale.y = 1 / ratio;   // image wider than plane -> shrink height
    else           mesh.scale.x = ratio;        // image taller -> shrink width
  });

  scene.add(mesh);
  meshes.push(mesh);
}

Keep mesh.userData.originalVertices — the distortion always reads from this pristine array, never from the already-bent live positions, so it never accumulates.

The vertex bend (applyDistortion, called per visible mesh per frame)

For every vertex, measure its distance from world origin using its local x and its world y (mesh position + local y), build a sine-shaped falloff that peaks at center and dies by distance 2, and write that as the vertex's Z. strength is the signed, velocity-scaled amount from the loop:

function applyDistortion(mesh, positionY, strength) {
  const positions = mesh.geometry.attributes.position;
  const original  = mesh.userData.originalVertices;
  for (let i = 0; i < positions.count; i++) {
    const x = original[i * 3];
    const y = original[i * 3 + 1];
    const distance = Math.sqrt(x * x + (positionY + y) ** 2);   // radial distance from screen center
    const falloff  = Math.max(0, 1 - distance / 2);             // 1 at center -> 0 at dist 2
    const bend     = Math.pow(Math.sin((falloff * Math.PI) / 2), 1.5);  // eased 0..1 hump
    positions.setZ(i, bend * strength);                          // push vertex toward/away from camera
  }
  positions.needsUpdate = true;
  mesh.geometry.computeVertexNormals();
}

Because strength can be negative (see signedDistortion), the sheet bulges toward the camera when scrolling one direction and away when scrolling the other — the whole column reads as a curved, direction-aware ripple centered on the middle of the screen.

State (module-level)
let scrollPosition = 0, scrollTarget = 0, scrollMomentum = 0, isScrolling = false, lastFrameTime = 0;
let distortionAmount = 0, distortionTarget = 0, velocityPeak = 0, scrollDirection = 0, directionTarget = 0;
const velocityHistory = [0, 0, 0, 0, 0];                 // rolling window of 5 samples
let isDragging = false, dragStartY = 0, dragDelta = 0, touchStartY = 0, touchLastY = 0;
let activeSlideIndex = -1;
const addDistortionBurst = (amount) => { distortionTarget = Math.min(1, distortionTarget + amount); };
Input handlers (exact math — all on window, drag also toggles the canvas cursor)
  • Wheel ({ passive:false }, preventDefault): clamp the delta to ±wheelMax, add a distortion

burst proportional to it, advance the scroll target, and mark scrolling with a 150 ms debounce: ``js const clampedDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), config.wheelMax); addDistortionBurst(Math.abs(clampedDelta) * 0.001); scrollTarget += clampedDelta * config.wheelSpeed; isScrolling = true; clearTimeout(window._scrollTimeout); window._scrollTimeout = setTimeout(() => (isScrolling = false), 150); ``

  • Touchstart ({ passive:false }): `touchStartY = touchLastY = e.touches[0].clientY;

isScrolling = false; scrollMomentum = 0;`

  • Touchmove ({ passive:false }, preventDefault):

``js const deltaY = e.touches[0].clientY - touchLastY; touchLastY = e.touches[0].clientY; addDistortionBurst(Math.abs(deltaY) * 0.02); scrollTarget -= deltaY * config.touchSpeed; // drag up -> scroll forward isScrolling = true; ``

  • Touchend: compute a swipe velocity; if it exceeds a threshold, fling with momentum and a big

distortion burst, then release scrolling after 800 ms: ``js const swipeVelocity = (touchLastY - touchStartY) * 0.005; if (Math.abs(swipeVelocity) > 0.5) { scrollMomentum = -swipeVelocity * config.touchMomentum; addDistortionBurst(Math.abs(swipeVelocity) * 0.45); isScrolling = true; setTimeout(() => (isScrolling = false), 800); } ``

  • Mouse dragcanvas.style.cursor = "grab" initially.
  • mousedown: `isDragging = true; dragStartY = e.clientY; dragDelta = 0; scrollMomentum = 0;

canvas.style.cursor = "grabbing";`

  • mousemove (only while dragging): `deltaY = e.clientY - dragStartY; dragStartY = e.clientY;

dragDelta = deltaY; addDistortionBurst(Math.abs(deltaY) * 0.02); scrollTarget -= deltaY * config.dragSpeed; isScrolling = true;`

  • mouseup: isDragging = false; canvas.style.cursor = "grab"; then if |dragDelta| > 2:

scrollMomentum = -dragDelta * config.dragMomentum; addDistortionBurst(Math.abs(dragDelta) * 0.005); isScrolling = true; and setTimeout(() => (isScrolling = false), 800);

  • Resize: update camera.aspect, camera.updateProjectionMatrix(), and renderer.setSize(...).
The render loop (animate(time) — recursive rAF, this is the whole engine)
function animate(time) {
  requestAnimationFrame(animate);
  const deltaTime = lastFrameTime ? (time - lastFrameTime) / 1000 : 0.016;
  lastFrameTime = time;
  const previousScroll = scrollPosition;

  // 1. momentum (only while "scrolling"): add, then decay by friction, snap tiny to 0
  if (isScrolling) {
    scrollTarget += scrollMomentum;
    scrollMomentum *= config.momentumFriction;                 // 0.95
    if (Math.abs(scrollMomentum) < config.momentumThreshold) scrollMomentum = 0;
  }

  // 2. smooth the rendered scroll toward its target
  scrollPosition += (scrollTarget - scrollPosition) * config.smoothing;   // 0.05
  const frameDelta = scrollPosition - previousScroll;

  // 3. smoothed scroll DIRECTION (-1..1), lerped so sign flips ease in
  if (Math.abs(frameDelta) > 0.00001) directionTarget = frameDelta > 0 ? 1 : -1;
  scrollDirection += (directionTarget - scrollDirection) * 0.08;

  // 4. velocity + 5-sample rolling average, and a decaying peak tracker
  const velocity = Math.abs(frameDelta) / deltaTime;
  velocityHistory.push(velocity); velocityHistory.shift();
  const averageVelocity = velocityHistory.reduce((a, b) => a + b) / velocityHistory.length;
  if (averageVelocity > velocityPeak) velocityPeak = averageVelocity;
  const isDecelerating = averageVelocity / (velocityPeak + 0.001) < 0.7 && velocityPeak > 0.5;
  velocityPeak *= 0.99;

  // 5. drive the distortion target from speed, and shrink it when slow/decelerating
  if (velocity > 0.05) distortionTarget = Math.max(distortionTarget, Math.min(1, velocity * 0.1));
  if (isDecelerating || averageVelocity < 0.2)
    distortionTarget *= isDecelerating ? 0.95 : 0.855;
  distortionAmount += (distortionTarget - distortionAmount) * config.distortionSmoothing;  // 0.1
  const signedDistortion = distortionAmount * scrollDirection;   // <- signed by direction

  // 6. place every mesh in the wrapped column, bend the visible ones, track the centered slide
  let closestDistance = Infinity, closestIndex = 0;
  meshes.forEach((mesh) => {
    const { offset } = mesh.userData;
    let y = -(offset - wrap(scrollPosition, loopLength));
    y = wrap(y + halfLoop, loopLength) - halfLoop;    // recenter into [-halfLoop, halfLoop]
    mesh.position.y = y;
    if (Math.abs(y) < closestDistance) { closestDistance = Math.abs(y); closestIndex = mesh.userData.index; }
    if (Math.abs(y) < halfLoop + config.maxHeight)
      applyDistortion(mesh, y, config.distortionStrength * signedDistortion);
  });

  // 7. update the HTML title + counter when the centered slide changes
  if (closestIndex !== activeSlideIndex) {
    activeSlideIndex = closestIndex;
    titleElement.textContent  = slides[activeSlideIndex].name;
    counterElement.textContent = zeroPad(activeSlideIndex + 1);   // "01".."10"
  }

  renderer.render(scene, camera);
}
animate();
What makes the feel (keep every constant)
  • Infinite loop: step 6's double-wrap maps each slide's fixed offset into `[-halfLoop,

halfLoop]` around the live scroll, so planes leaving one edge reappear at the other — endless in both directions with only 10 meshes.

  • Two-stage smoothing + momentum: smoothing 0.05 lerps the rendered scroll toward the target;

on release, scrollMomentum (from drag/swipe) keeps feeding the target while decaying at friction 0.95 until it drops under 0.001. That is the weighty glide-to-stop.

  • Signed, velocity-reactive bend: distortionTarget rises with instantaneous velocity (capped

at 1), input bursts add to it, and it decays faster when the rolling average shows you're decelerating (×0.95) or nearly idle (×0.855). distortionAmount lerps toward it at 0.1. Multiplying by the smoothed scrollDirection (−1..1) gives signedDistortion, so the sheet curves toward the camera one way and away the other, and flattens to zero when you stop.

  • The per-frame computeVertexNormals() after each bend is what keeps the double-sided planes

shading correctly as they deform.

Assets / images

10 slide textures — an editorial set split between high-contrast black-and-white portrait crops (the majority) and a few warm-toned natural macro / still-life frames on dark grounds. The B&W frames set the tone; the warm nature shots are the accents. Real subjects and dominant colors in the visible set:

  • B&W portrait crops (the dominant look): a straight-on cropped close-up of a young man's face —

cropped at hairline and mouth, eyes to camera, on a plain pale-grey studio ground (neutral greys, landscape ~4:3); a woman's profile with a sleek dark bob and a single pearl earring over a black blazer collar, against light grey (portrait/tall ~2:3 — the one clearly vertical frame); a cropped lips/chin/neck rising out of a black blazer lapel, dramatic side light on grey (charcoal + grey, landscape ~4:3); a mouth and chin seen through translucent sheer fabric / a veil that fills most of the frame in soft high-key white-grey (near-white, landscape ~3:2).

  • Warm natural macro / still life (the accents): curled, veined dried leaves glowing

copper/rust, lit against a near-black ground (deep warm browns + amber, landscape ~3:2); slender backlit seedlings / sprouts rimmed gold against a dark violet-blue bokeh (olive-gold on deep purple — the coolest background in the set, landscape ~3:2); two pale cream moths with faint pink edges on a rich dark burgundy velvet ground (ivory on maroon, landscape ~3:2).

  • One remaining warm frame continues in the same register: a golden backlit smiling woman with

curly dark hair and white earbuds, blown-out sun flare over a Mediterranean courtyard (warm ambers/creams, landscape ~4:3).

Aspect ratios vary — most are landscape ~3:2 / 4:3, one is clearly portrait/tall (the B&W profile). Exact aspect doesn't matter: each mesh contain-fits its own texture by scaling one axis, so nothing stretches. No logos or baked-in text. (For a faithful repro, any 10 high-detail, tonal photos work; the bend and loop show best on textured frames — the set stays cohesive by pairing moody B&W portrait crops with a few warm, dark-ground nature/still-life shots.)

Behavior notes

  • Interaction-driven, no autoplay: the column only moves on wheel / drag / touch; when idle it

sits still and fully flat (distortion eases to 0).

  • Whole viewport is the surface — the fixed canvas fills the screen; the page itself never

scrolls (wheel/touch are preventDefaulted and consumed by the virtual scroll).

  • pixelRatio capped at 2. MeshBasicMaterial means no lighting setup. No reduced-motion branch and

no mobile/desktop layout switch in the original — the same scene runs everywhere, driven by whichever input the device provides.

  • preserveDrawingBuffer: true is set on the renderer (needed for reliable off-thread capture /

thumbnails); keep it.