ThreeJS 3D Curved Slider (WebGL canvas-texture, Lenis scroll, no GSAP)
Goal
Build a full-screen 3D curved plane that displays an endless vertical slideshow of captioned images, rendered entirely in Three.js. Seven images with title captions are painted onto a tall repeating 2D canvas that is used as a texture on one big parabolically curved plane tilted in 3D space. Lenis smooth-scroll progress (0→1 over a very tall page) is fed into a scroll listener that shifts the texture's vertical offset and re-renders the WebGL scene, so scrolling pushes the strip of images continuously up through the curved 3D sheet and loops forever. The star effect: the image strip appears to bend and flow over a concave wave because the flat canvas is mapped onto a plane whose vertices curve toward the camera at the top and bottom edges, viewed from an angled, rolled camera. A dark radial vignette overlay and fixed nav/footer frame the scene.
Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by Vite (npm project).
three(npm) importedimport * as THREE from "three";andlenis(npm) importedimport Lenis from "lenis";.- No GSAP, no ScrollTrigger, no shaders. All motion is: Lenis for smooth scroll + a
lenis.on("scroll", …)callback that redraws a 2D<canvas>texture and callsrenderer.render(). There is no continuous rAF render loop — the scene renders once at init and then only on scroll events.MeshBasicMaterial(unlit) — no lights. - Everything runs inside a single
window.addEventListener("load", …)in onescript.js.
Layout / HTML
Almost no DOM — a fixed nav, a fixed footer, the WebGL <canvas> inside a wrapper, and a vignette overlay. Text is neutral/fictional demo copy (no real brands). Class names are load-bearing (.slider-wrapper, .overlay).
<nav>
<div class="site-info">
<p id="logo">Motionprompts</p>
<p>YouTube Channel</p>
</div>
<div class="nav-links">
<p>Index</p>
<p>About</p>
</div>
</nav>
<footer>
<p>Experiment 0410</p>
<p>© 2024</p>
</footer>
<div class="slider-wrapper">
<canvas></canvas>
</div>
<div class="overlay"></div>
<script type="module" src="./script.js"></script>
document.querySelector("canvas") is passed straight into the renderer. Use the copy above verbatim; "Motionprompts" is the fictional demo name.
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }
- Page = scroll runway.
html, body { width:100%; height:1000vh; font-family:"Inter"; background:#000; color:#fff; }— the body is 1000vh tall; that giant page height is the entire scroll distance Lenis reports and the only thing that drives the slideshow. Nothing else scrolls. - Font: Inter (system sans fallback fine).
p { font-size:13px; font-weight:400; line-height:1.5; opacity:0.5; }andp#logo { opacity:1; }— small dimmed labels, logo at full opacity. nav—position:fixed; top:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2;..nav-links { display:flex; gap:2em; }(site-info left, nav-links right).footer—position:fixed; bottom:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2;..slider-wrapper—position:fixed; width:100vw; height:100vh; overflow:hidden;.canvas—position:fixed; top:0; left:0; width:100%; height:100%;(fills the viewport behind everything)..overlay—position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:1;with a radial vignette:background: radial-gradient(circle, rgba(0,0,0,0) 75%, rgba(0,0,0,0.5) 100%);— transparent center, darkening to 50% black at the corners. Sits above the canvas (z-index 1) but below nav/footer (z-index 2).
The star effect — Three.js curved plane + canvas-texture scroll (be exact)
Near-verbatim port. Reproduce the constants, the geometry bend, the texture-draw math, the camera placement, and the scroll wiring exactly.
Smooth scroll (Lenis, self-driven rAF)
Create Lenis with default options and drive its own rAF loop (this loop only powers Lenis; it does not render the scene):
const lenis = new Lenis();
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
Image preload gate
Preload all 7 images (/…/img1.jpg … img7.jpg) into an images[] array; count onload/onerror, and only call initializeScene() once all 7 have resolved. This guarantees every slide texture is ready before the first paint.
Renderer, scene, camera
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector("canvas"),
antialias: true,
powerPreference: "high-performance",
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000);
The curved plane geometry (the "wave")
One large subdivided plane whose vertices are bent along Z by a parabola of their Y:
const parentWidth = 20;
const parentHeight = 75;
const curvature = 35;
const segmentsX = 200;
const segmentsY = 200;
const parentGeometry = new THREE.PlaneGeometry(parentWidth, parentHeight, segmentsX, segmentsY);
const positions = parentGeometry.attributes.position.array;
for (let i = 0; i < positions.length; i += 3) {
const y = positions[i + 1];
const distanceFromCenter = Math.abs(y / (parentHeight / 2)); // 0 at vertical center → 1 at top/bottom edge
positions[i + 2] = Math.pow(distanceFromCenter, 2) * curvature; // Z = dist² × 35
}
parentGeometry.computeVertexNormals();
So the plane is flat (z=0) across its vertical middle and curls forward (+z up to 35) toward its top and bottom edges — a tall concave trough. This is what makes the image strip read as flowing over a 3D wave.
Material, mesh, tilt
const parentMaterial = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide });
const parentMesh = new THREE.Mesh(parentGeometry, parentMaterial);
parentMesh.position.set(0, 0, 0);
parentMesh.rotation.x = THREE.MathUtils.degToRad(-20);
parentMesh.rotation.y = THREE.MathUtils.degToRad(20);
scene.add(parentMesh);
The plane is tilted −20° on X and +20° on Y so we see it in perspective, not head-on.
Camera placement (angled orbit + roll)
Position the camera on a 17.5-unit orbit at the same 20° azimuth as the mesh, lifted 5 units, aimed slightly below center, and rolled −5°:
const distance = 17.5;
const heightOffset = 5;
const offsetX = distance * Math.sin(THREE.MathUtils.degToRad(20)); // ≈ 5.99
const offsetZ = distance * Math.cos(THREE.MathUtils.degToRad(20)); // ≈ 16.44
camera.position.set(offsetX, heightOffset, offsetZ);
camera.lookAt(0, -2, 0);
camera.rotation.z = THREE.MathUtils.degToRad(-5); // slight roll
The texture canvas (where slides are drawn)
A single tall offscreen 2D canvas becomes the repeating texture:
const textureCanvas = document.createElement("canvas");
const ctx = textureCanvas.getContext("2d", { alpha: false, willReadFrequently: false });
textureCanvas.width = 2048;
textureCanvas.height = 8192; // tall strip
const texture = new THREE.CanvasTexture(textureCanvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping; // vertical wrap → seamless loop
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
Slide layout constants + titles
const totalSlides = 7;
const slideHeight = 15;
const gap = 0.5;
const cycleHeight = totalSlides * (slideHeight + gap); // 7 × 15.5 = 108.5
const slideTitles = [
"Field Unit", "Astral Convergence", "Eclipse Core",
"Luminous", "Serenity", "Nebula Point", "Horizon",
]; // neutral one/two-word titles — no real brands
updateTexture(offset) — redraw the canvas each scroll frame
offset is a normalized cycle offset (0 → one full loop). It clears to black, sets the caption font, then draws slides -2 … totalSlides+1 (two extra above and below so the strip never shows a gap at the seam), each wrapped modulo the canvas height:
function updateTexture(offset = 0) {
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, textureCanvas.width, textureCanvas.height);
const fontSize = 180;
ctx.font = `500 ${fontSize}px Dahlia`; // stylized display face, weight 500 (generic display fallback is fine)
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const extraSlides = 2;
for (let i = -extraSlides; i < totalSlides + extraSlides; i++) {
let slideY = -i * (slideHeight + gap);
slideY += offset * cycleHeight; // scroll shifts every slide
const textureY = (slideY / cycleHeight) * textureCanvas.height;
let wrappedY = textureY % textureCanvas.height;
if (wrappedY < 0) wrappedY += textureCanvas.height; // always-positive wrap
let slideIndex = ((-i % totalSlides) + totalSlides) % totalSlides; // 0..6, cycles
let slideNumber = slideIndex + 1;
const slideRect = {
x: textureCanvas.width * 0.05, // 5% left margin
y: wrappedY,
width: textureCanvas.width * 0.9, // 90% wide (≈1843px)
height: (slideHeight / cycleHeight) * textureCanvas.height, // ≈1132px per slide
};
const img = images[slideNumber - 1];
if (img) {
// COVER-fit the image into slideRect (fill rect, crop overflow, keep aspect)
const imgAspect = img.width / img.height;
const rectAspect = slideRect.width / slideRect.height;
let drawWidth, drawHeight, drawX, drawY;
if (imgAspect > rectAspect) {
drawHeight = slideRect.height;
drawWidth = drawHeight * imgAspect;
drawX = slideRect.x + (slideRect.width - drawWidth) / 2;
drawY = slideRect.y;
} else {
drawWidth = slideRect.width;
drawHeight = drawWidth / imgAspect;
drawX = slideRect.x;
drawY = slideRect.y + (slideRect.height - drawHeight) / 2;
}
ctx.save();
ctx.beginPath();
ctx.roundRect(slideRect.x, slideRect.y, slideRect.width, slideRect.height); // clip mask (radii default 0)
ctx.clip();
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
ctx.restore();
ctx.fillStyle = "white";
ctx.fillText(slideTitles[slideIndex], textureCanvas.width / 2, wrappedY + slideRect.height / 2);
}
}
texture.needsUpdate = true; // push the redrawn canvas to the GPU
}
Each slide is an image (cover-fit, clipped to its rect) with its white centered caption drawn on top. The caption sits at the vertical middle of each slide rect.
Scroll wiring (the whole engine)
Lenis reports scroll; convert to 0→1 progress, invert it into the texture offset, redraw, and render once per scroll event:
let currentScroll = 0;
lenis.on("scroll", ({ scroll, limit }) => {
currentScroll = scroll / limit; // 0 at top → 1 at bottom of the 1000vh page
updateTexture(-currentScroll); // negative → strip moves upward as you scroll down
renderer.render(scene, camera);
});
Because the texture uses RepeatWrapping and the draw loop wraps modulo the canvas height, the image strip loops seamlessly — scrolling down forever cycles the 7 slides upward through the curved plane; scrolling up reverses it exactly.
Init + resize
- After building everything, call
updateTexture(0)andrenderer.render(scene, camera)once so the first frame is visible before any scroll. - Resize (debounced 250 ms):
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight);.
Assets / images
7 slide images, mostly landscape (~3:2 / 4:3 / 16:9) with one portrait frame — exact aspect doesn't matter since each is cover-fit (cropped to fill) into its slide rect. They read as a moody, editorial/cinematic set on mostly dark grounds, mixing portraits, 3D-rendered still lifes, and abstract texture. Describe generically, by role:
- Woman in a peach silk blouse and pearl bracelets holding a vintage wooden transistor radio, warm retro tones (landscape).
- Side profile of a person reclining in a black cube-frame sling chair, with scribbled white light-lines drawn around their head (landscape).
- 3D-rendered still life of two pears, a stack of glossy plates and a knife on a golden marbled surface (landscape ~4:3).
- Close-up of a pale marble classical statue tinted with soft pink, green and yellow gradient light (landscape).
- Dim warm-lit portrait of a face partly obscured behind many taut horizontal strings (landscape).
- Abstract glossy brown 3D blobs with speckled sparkle, smooth organic curves (wide ~16:9).
- Classical oil-painting-style portrait of a young woman in a green turban and patterned blue-and-gold robe reading a book (portrait/tall).
No logos or baked-in brand text. If you have fewer than 7, repeat in order; the effect is identical regardless of content.
Behavior notes
- Scroll is the sole driver, and rendering is on-demand. There is no autoplay and no continuous animation loop — the scene renders once at init and then only inside the Lenis
scrollcallback. Idle = a static frame; movement only while scrolling. - The 1000vh body IS the runway. Lenis maps that height to
scroll/limit= 0→1; that single progress value drives the texture offset. Keep the body atheight:1000vh. - Endless loop comes from
RepeatWrapping+ the modulo wrap inupdateTexture+ the twoextraSlidesof overdraw — never a visible seam. - WebGL + heavy geometry (200×200 segments) means this is desktop-oriented / not mobile-safe.
pixelRatiocapped at 2,MeshBasicMaterial(no lights). No reduced-motion branch and no mobile layout switch in the original. - Keep
overflow:hiddenon.slider-wrapper, and the vignette.overlayabove the canvas but below the fixed nav/footer.