All components

ThreeJS 3D Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A Three.js curved plane displays an endless vertical slideshow of captioned images drawn onto a repeating 2D canvas texture. Lenis smooth-scroll progress feeds a scroll listener that shifts the texture offset and re-renders the WebGL scene, so scrolling the tall page pushes the image strip through the 3D wave.

How it's built

Category3d-webgl
Techthree, lenis
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

three.js webgl scroll slider canvas-texture lenis curved-plane editorial gallery

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 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) imported import * as THREE from "three"; and lenis (npm) imported import 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 calls renderer.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 one script.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>&copy; 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; } and p#logo { opacity:1; } — small dimmed labels, logo at full opacity.
  • navposition: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).
  • footerposition:fixed; bottom:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2;.
  • .slider-wrapperposition:fixed; width:100vw; height:100vh; overflow:hidden;.
  • canvasposition:fixed; top:0; left:0; width:100%; height:100%; (fills the viewport behind everything).
  • .overlayposition: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.jpgimg7.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) and renderer.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:

  1. Woman in a peach silk blouse and pearl bracelets holding a vintage wooden transistor radio, warm retro tones (landscape).
  2. Side profile of a person reclining in a black cube-frame sling chair, with scribbled white light-lines drawn around their head (landscape).
  3. 3D-rendered still life of two pears, a stack of glossy plates and a knife on a golden marbled surface (landscape ~4:3).
  4. Close-up of a pale marble classical statue tinted with soft pink, green and yellow gradient light (landscape).
  5. Dim warm-lit portrait of a face partly obscured behind many taut horizontal strings (landscape).
  6. Abstract glossy brown 3D blobs with speckled sparkle, smooth organic curves (wide ~16:9).
  7. 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 scroll callback. 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 at height:1000vh.
  • Endless loop comes from RepeatWrapping + the modulo wrap in updateTexture + the two extraSlides of overdraw — never a visible seam.
  • WebGL + heavy geometry (200×200 segments) means this is desktop-oriented / not mobile-safe. pixelRatio capped at 2, MeshBasicMaterial (no lights). No reduced-motion branch and no mobile layout switch in the original.
  • Keep overflow:hidden on .slider-wrapper, and the vignette .overlay above the canvas but below the fixed nav/footer.