All components

ThreeJS Curved Distortion Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

An infinite horizontal Three.js image slider built from textured PlaneGeometry meshes whose vertices bend into a radial curve as you scroll or drag. Wheel, touch and arrow-key input feed velocity-tracked position and distortion factors that are lerped every frame, so faster flicks warp the slides more and the whole strip wraps seamlessly.

How it's built

Categoryslider
Techthree
Complexitypage
Performance costheavy
Mobile-safedesktop-first

three.js webgl infinite-slider vertex-distortion drag momentum experimental gallery cinematic

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

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 }) where color cycles through 5 placeholder hexes ["#FF5733", "#33FF57", "#3357FF", "#F3FF33", "#FF33F3"] (visible only until the texture loads).
  • mesh.position.x = index * slideUnit, and store in mesh.userData: a copy of the original position array ([...geometry.attributes.position.array]) and the index.
  • Load the texture asynchronously with THREE.TextureLoader. On load: set texture.colorSpace = THREE.SRGBColorSpace, assign material.map = texture, reset material.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; otherwise mesh.scale.x = imgAspect / slideAspect.
  • Image paths: ./images/img1.jpgimg5.jpg, slide i uses 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: touchstart records touchStartX = touchLastX = touches[0].clientX, isScrolling = false. touchmove (passive: false, preventDefault) uses the incremental delta from touchLastX: targetDistortionFactor = min(1, target + |deltaX| * 0.02), targetPosition -= deltaX * settings.touchSensitivity, isScrolling = true. touchend computes 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, then isScrolling = false after an 800ms timeout.
  • Keyboard: ArrowLefttargetPosition += slideUnit; ArrowRighttargetPosition -= slideUnit; both also targetDistortionFactor = 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):

  1. 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.
  2. Master lerp: currentPosition += (targetPosition - currentPosition) * settings.smoothing (×0.1).
  3. Velocity tracking: currentVelocity = |currentPosition - prevPosition| / deltaTime. Push into the 5-entry velocityHistory (shift the oldest out), average it. Track peakVelocity = max(peakVelocity, avgVelocity), and decay it by ×0.99 every frame. isDecelerating = (avgVelocity / (peakVelocity + 0.001)) < 0.7 && peakVelocity > 0.5.
  4. Velocity → distortion: movementDistortion = Math.min(1.0, currentVelocity * 0.1); if currentVelocity > 0.05, targetDistortionFactor = Math.max(targetDistortionFactor, movementDistortion). If isDecelerating || avgVelocity < 0.2, decay: targetDistortionFactor *= isDecelerating ? settings.distortionDecay : settings.distortionDecay * 0.9 (0.95 or 0.855).
  5. Distortion lerp: currentDistortionFactor += (targetDistortionFactor - currentDistortionFactor) * settings.distortionSmoothing (×0.075).
  6. 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.

  1. 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.