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, importedimport * 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).
section—position: relative; width: 100%; height: 100svh; overflow: hidden;canvas—position: 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
#141414set 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 drag —
canvas.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(), andrenderer.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-
wrapmaps each slide's fixedoffsetinto `[-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.05lerps 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:
distortionTargetrises 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).
pixelRatiocapped at 2.MeshBasicMaterialmeans 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: trueis set on the renderer (needed for reliable off-thread capture /
thumbnails); keep it.