Three.js Infinite Curved-Distortion Slider — Velocity-Reactive Vertex Warp
Goal
Build a full-viewport, infinitely looping horizontal image slider rendered entirely in Three.js. Ten textured planes drift left/right in 3D space, driven by mouse wheel, touch drag, or arrow keys. The star effect: as the strip moves, the planes bend toward the camera in a radial bulge centered on the middle of the screen — a curved-screen distortion whose intensity is proportional to scroll velocity. Fast flicks warp the slides dramatically; when motion settles, the planes relax back to perfectly flat. The strip wraps seamlessly, so you can scroll forever in either direction. Everything is lerped every frame — position, per-slide easing, and the distortion factor itself — so the whole thing feels weighty and fluid, never snappy.
Tech
Vanilla HTML/CSS/JS with ES module imports. three (npm) only — no GSAP, no smooth-scroll library. The entire engine is a hand-rolled requestAnimationFrame loop with linear interpolation and a velocity tracker. import * as THREE from "three";
Layout / HTML
nav (fixed top strip — two small labels)
p "[ Silhouette ]"
p "/ Experiment by Silhouette"
footer (fixed bottom strip — two small labels)
p "Infinite WebGL Slider"
p "Scroll to explore ↓"
canvas#canvas (the Three.js render target, fills the viewport)
The <script type="module" src="./script.js"> goes at the end of <body>.
Styling
Minimal — the canvas is the whole show.
- Global reset
* { margin: 0; padding: 0; box-sizing: border-box; }. body { font-family: "Akkurat Mono", monospace; background-color: #e3e3db; color: #0f0f0f; }(no webfont needed — the monospace fallback is fine).p { text-transform: uppercase; font-size: 13px; font-weight: 600; letter-spacing: -0.02em; -webkit-font-smoothing: antialiased; }nav, footer { position: fixed; width: 100vw; padding: 2em; display: flex; justify-content: space-between; align-items: center; z-index: 2; opacity: 0; }— yes,opacity: 0: the labels are intentionally invisible in this demo (kept in the DOM but hidden).nav { top: 0 },footer { bottom: 0 }.#canvas { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; overflow: hidden; }
The Three.js engine (exhaustive — this is the effect)
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(0xe3e3db); // matches the page background exactly
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;
On resize: update camera.aspect, camera.updateProjectionMatrix(), renderer.setSize(...).
Constants and settings (use these exact values)
const settings = {
wheelSensitivity: 0.01,
touchSensitivity: 0.01,
momentumMultiplier: 2,
smoothing: 0.1, // lerp factor: currentPosition → targetPosition
slideLerp: 0.075, // lerp factor: each slide's currentX → targetX
distortionDecay: 0.95,
maxDistortion: 2.5, // max Z bulge in world units at distortionFactor = 1
distortionSensitivity: 0.15,
distortionSmoothing: 0.075 // lerp factor: currentDistortionFactor → targetDistortionFactor
};
const slideWidth = 3.0; // world units
const slideHeight = 1.5; // world units (2:1 landscape planes)
const gap = 0.1;
const slideCount = 10;
const imagesCount = 5; // textures cycle: slide i uses image (i % 5) + 1
const totalWidth = slideCount * (slideWidth + gap); // 31
const slideUnit = slideWidth + gap; // 3.1
State variables: currentPosition, targetPosition, isScrolling, autoScrollSpeed, lastTime, touchStartX, touchLastX, currentDistortionFactor, targetDistortionFactor, peakVelocity, and velocityHistory = [0, 0, 0, 0, 0] (a 5-entry rolling window).
Building the slides
For each of the 10 slides:
new THREE.PlaneGeometry(slideWidth, slideHeight, 32, 16)— the 32×16 segment resolution is load-bearing; the distortion bends individual vertices.- Material:
THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide })wherecolorcycles through 5 placeholder hexes["#FF5733", "#33FF57", "#3357FF", "#F3FF33", "#FF33F3"](visible only until the texture loads). mesh.position.x = index * slideUnit, and store inmesh.userData: a copy of the original position array ([...geometry.attributes.position.array]) and the index.- Load the texture asynchronously with
THREE.TextureLoader. On load: settexture.colorSpace = THREE.SRGBColorSpace, assignmaterial.map = texture, resetmaterial.color.set(0xffffff),material.needsUpdate = true. Then aspect-fit (contain) the image by scaling the mesh: if the image is wider than the 2:1 slide,mesh.scale.y = slideAspect / imgAspect; otherwisemesh.scale.x = imgAspect / slideAspect. - Image paths:
./images/img1.jpg…img5.jpg, slideiuses image(i % 5) + 1.
After creating all slides, recenter the strip: subtract totalWidth / 2 from every slide's position.x, and initialize slide.userData.targetX = slide.userData.currentX = slide.position.x.
The vertex distortion (updateCurve(mesh, worldPositionX, distortionFactor))
A radial bulge fixed at world center (0, 0) with radius 2.0. For every vertex of the plane (reading X/Y from the stored *original* vertices, never the mutated ones):
const vertexWorldPosX = worldPositionX + x; // x, y = original local vertex coords
const distFromCenter = Math.sqrt((vertexWorldPosX - 0) ** 2 + (y - 0) ** 2);
const distortionStrength = Math.max(0, 1 - distFromCenter / 2.0);
const curveZ = Math.pow(Math.sin((distortionStrength * Math.PI) / 2), 1.5)
* settings.maxDistortion * distortionFactor;
positionAttribute.setZ(i, curveZ);
Then positionAttribute.needsUpdate = true and mesh.geometry.computeVertexNormals(). The sin^1.5 falloff makes a smooth dome: vertices near screen center push up to 2.5 × distortionFactor world units toward the camera, fading to zero at radius 2.
Input handlers (all on window)
- Wheel (
{ passive: false },e.preventDefault()): targetDistortionFactor = Math.min(1.0, targetDistortionFactor + Math.abs(e.deltaY) * 0.001)targetPosition -= e.deltaY * settings.wheelSensitivity(×0.01)isScrolling = true;autoScrollSpeed = Math.min(Math.abs(e.deltaY) * 0.0005, 0.05) * Math.sign(e.deltaY)— a momentum kick in the scroll direction.- Debounce: clear + set a 150ms timeout that flips
isScrolling = false. - Touch:
touchstartrecordstouchStartX = touchLastX = touches[0].clientX,isScrolling = false.touchmove(passive: false, preventDefault) uses the incremental delta fromtouchLastX:targetDistortionFactor = min(1, target + |deltaX| * 0.02),targetPosition -= deltaX * settings.touchSensitivity,isScrolling = true.touchendcomputes flick velocity(touchLastX - touchStartX) * 0.005; if|velocity| > 0.5:autoScrollSpeed = -velocity * settings.momentumMultiplier * 0.05,targetDistortionFactor = min(1, |velocity| * 3 * settings.distortionSensitivity),isScrolling = true, thenisScrolling = falseafter an 800ms timeout. - Keyboard:
ArrowLeft→targetPosition += slideUnit;ArrowRight→targetPosition -= slideUnit; both alsotargetDistortionFactor = Math.min(1.0, targetDistortionFactor + 0.3)— one keypress steps exactly one slide and pulses the warp.
The rAF loop (runs forever)
Per frame, with deltaTime in seconds (fallback 0.016 on the first frame):
- Momentum: if
isScrolling,targetPosition += autoScrollSpeed, then decay it:autoScrollSpeed *= Math.max(0.92, 0.97 - Math.abs(autoScrollSpeed) * 0.5); zero it below 0.001. - Master lerp:
currentPosition += (targetPosition - currentPosition) * settings.smoothing(×0.1). - Velocity tracking:
currentVelocity = |currentPosition - prevPosition| / deltaTime. Push into the 5-entryvelocityHistory(shift the oldest out), average it. TrackpeakVelocity = max(peakVelocity, avgVelocity), and decay it by×0.99every frame.isDecelerating = (avgVelocity / (peakVelocity + 0.001)) < 0.7 && peakVelocity > 0.5. - Velocity → distortion:
movementDistortion = Math.min(1.0, currentVelocity * 0.1); ifcurrentVelocity > 0.05,targetDistortionFactor = Math.max(targetDistortionFactor, movementDistortion). IfisDecelerating || avgVelocity < 0.2, decay:targetDistortionFactor *= isDecelerating ? settings.distortionDecay : settings.distortionDecay * 0.9(0.95 or 0.855). - Distortion lerp:
currentDistortionFactor += (targetDistortionFactor - currentDistortionFactor) * settings.distortionSmoothing(×0.075). - Per-slide wrap + ease — for each slide
i:
```js let baseX = i * slideUnit - currentPosition; baseX = ((baseX % totalWidth) + totalWidth) % totalWidth; // wrap into [0, totalWidth) if (baseX > totalWidth / 2) baseX -= totalWidth; // center into [-15.5, 15.5]
// teleport (don't ease) when a slide wraps around an edge: if (Math.abs(baseX - slide.userData.targetX) > slideWidth * 2) slide.userData.currentX = baseX;
slide.userData.targetX = baseX; slide.userData.currentX += (slide.userData.targetX - slide.userData.currentX) * settings.slideLerp; // ×0.075
const wrapThreshold = totalWidth / 2 + slideWidth; // 18.5 if (Math.abs(slide.userData.currentX) < wrapThreshold * 1.5) { slide.position.x = slide.userData.currentX; updateCurve(slide, slide.position.x, currentDistortionFactor); } ``` The per-slide 0.075 lerp on top of the master 0.1 lerp gives the strip a subtle elastic follow-through; the teleport check makes the infinite wrap invisible.
renderer.render(scene, camera).
Assets / images
5 abstract, cinematic art images (they repeat across the 10 slides in order 1-2-3-4-5-1-2-3-4-5). They display aspect-fit inside 2:1 landscape planes, so landscape sources look best. They should read as one cohesive experimental series — think motion-blurred thermal-camera silhouettes, swirling fluid-ink vortices on warm gradients, neon heat-map figures with raised arms, dancing silhouettes against sunburst gradients. Bold saturated color fields (yellow/red/orange, violet/indigo, magenta) with soft blur and movement.
Behavior notes
- The strip is truly infinite both ways — the modulo wrap plus teleport check means no visible ends and no snap when a slide recycles.
- The page never scrolls; the wheel is fully hijacked (
preventDefault) to drive the slider. Touch drag works the same way on mobile. - With no input, the strip glides to a stop (momentum decay) and the bulge melts away (distortion decay) until the planes are flat again.
- Slide distortion depends on world position, not slide identity: whichever slide passes through screen center bulges the most, its neighbors bend partially where they enter the radius-2 zone.
- The nav/footer labels are rendered but invisible (
opacity: 0) — keep them anyway for structure fidelity.