All components

ThreeJS Infinite Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

Infinite vertical slider built entirely in Three.js (no GSAP): ten textured planes loop endlessly inside a WebGL scene, driven by mouse wheel, drag and touch with lerped smoothing and momentum friction. Scroll velocity bends each plane's vertices along Z with a sine falloff, creating a directional curvature distortion, while an HTML title and counter track the slide closest to center.

How it's built

Categoryslider
Techthree
Complexitysection
Performance costheavy
Mobile-safeyes

three.js webgl slider infinite-loop vertical wheel drag touch vertex-distortion momentum lerp

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 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, imported import * 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).

  • sectionposition: relative; width: 100%; height: 100svh; overflow: hidden;
  • canvasposition: 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 #141414 set 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 dragcanvas.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(), and renderer.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-wrap maps each slide's fixed offset into `[-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.05 lerps 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: distortionTarget rises 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).

  • pixelRatio capped at 2. MeshBasicMaterial means 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: true is set on the renderer (needed for reliable off-thread capture /

thumbnails); keep it.

Images

This component ships with 10 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/threejs-slider/img1.jpg
https://motionprompts.dev/c/threejs-slider/img10.jpg
https://motionprompts.dev/c/threejs-slider/img2.jpg
https://motionprompts.dev/c/threejs-slider/img3.jpg
https://motionprompts.dev/c/threejs-slider/img4.jpg
https://motionprompts.dev/c/threejs-slider/img5.jpg
… 4 more under https://motionprompts.dev/c/threejs-slider/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --ivory. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.

Adapting this to React

Everything above describes a mount(config) / destroy() pair that already tears itself down: one call builds the renderer, scene, camera, the ten textured planes, the eight window listeners for wheel, touch, drag and resize, and the animate() loop that drives all of it, and the function it returns cancels the frame, removes all eight listeners, clears every pending debounce timeout, disposes each mesh's geometry, material and texture, and force-loses the WebGL context. This component was written to survive being re-invoked by this catalog's own editor runtime (window.MP.register), so most of the discipline a React effect needs is already on the page — but mount/destroy were built for one external caller re-triggering them deliberately, not for React's own remount timing, and the two do not line up for free. Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Call mount() without wiring its return value back as the effect's own cleanup, and the second mount builds a second WebGLRenderer racing to draw into a second cloned <canvas>, binds a second set of wheel/touch/drag listeners to the same window, and starts a second animate() loop stacking its own ten planes on top of the first — two columns answering the same wheel event, drawn one over the other. It will not reproduce in a production build, because only development does the double mount. Treat destroy() as the cleanup itself, not as something the effect calls in addition to its own teardown.

*(1) The entry point* — the bottom of the file checks window.MP first, and only in its absence checks document.readyState before deciding whether to wait for DOMContentLoaded. Both branches exist for the standalone demo and this catalog's visual editor; neither has a job inside a host React component. Delete the whole if/else, including the window.MP.register branch, and call mount({ ...DEFAULTS }) directly inside a useEffect with an empty dependency array, keeping its return value as the effect's own cleanup. useEffect already runs after the DOM is committed, so the race the readyState guard protects against cannot happen here.

*(2) Element lookups* — mount resolves canvas, p#slide-title and p#slide-count against the document and bails to a no-op destroy if the canvas is missing; keep that guard, but scope all three lookups to a root ref instead of document.querySelector. During the StrictMode remount two copies of this subtree exist for an instant, and an unscoped canvas selector can bind to the copy already on its way out. Keep the line right after the lookup, too: before touching the canvas, mount clones it and replaces the original (existing.cloneNode(false) / existing.replaceWith(canvas)), specifically so a remount never inherits a <canvas> whose WebGL context the previous mount's forceContextLoss() already killed. Dropping that clone because "the ref already points at the right node" reintroduces the black-canvas-on-remount failure it exists to prevent.

*(3) Cleanup* — four things unmount here, and only three are already guarded. The animate loop's own handle (frame = requestAnimationFrame(animate), cancelled with cancelAnimationFrame(frame)) and the debounce timeouts collected in the timers Set (the wheel handler's "stopped scrolling" reset and the drag/swipe momentum-release resets) are both already tracked and torn down correctly — keep that shape. The eight window listeners must stay addEventListener calls inside the effect rather than JSX onWheel/onTouchMove props: this handler calls e.preventDefault() on wheel and touchmove specifically to stop the page itself from scrolling, which requires the { passive: false } option this code already passes — React's synthetic wheel/touch handlers are attached passively at the root and cannot make that same call. What is *not* guarded today: textureLoader.load(slides[i].img, callback) is fire-and-forget, and its callback can land after a StrictMode unmount has already run destroy(). When that happens it writes material.map, material.color and mesh.scale onto a material destroy() has already disposed and a mesh already pulled out of the scene — harmless to look at, since nothing renders again, but the texture object the callback just decoded is never disposed, so every remount that catches a slow image mid-flight leaks one GPU texture per straggling slide. Add a cancellation flag: set it in the returned cleanup, check it at the top of each texture callback, and call texture.dispose() instead of assigning it when the flag is already set.

*(4) Rendering this in @react-three/fiber* — three 0.185, @react-three/fiber 9, drei 10.7, React 19. <Canvas> replaces the WebGLRenderer/Scene/PerspectiveCamera block: carry antialias and preserveDrawingBuffer into its gl prop, cap dpr at the same value the manual setPixelRatio call uses, and give the camera the same field of view, near/far planes and starting Z. Do not switch this one to frameloop="demand": animate() keeps running the two-stage smoothing and the distortion decay every frame even at rest — momentum bleeding off, the bend easing back to flat — so freezing the loop between explicit invalidate() calls would freeze that idle decay too. Pin <Canvas> to its default continuous loop, and move the whole per-frame body of animate (steps 1–7) into the useFrame callback of a component rendered inside it.

The vertex bend stays exactly as imperative as it is written today: applyDistortion mutates geometry.attributes.position in place every frame and is not a candidate for a declarative rewrite. Build each plane's pristine originalVertices copy once, in a useMemo keyed on that slide's height, store it alongside the mesh in a ref array parallel to slides, and mutate positions from that ref inside useFrame exactly as mount does today — deriving it from the live, already-bent positions instead would accumulate the same drift the comment already in this file warns against. Replace textureLoader.load's callback with drei's useTexture: it suspends until the texture decodes, so the letterbox aspect-fit (mesh.scale.x/scale.y from imageAspect/planeAspect) runs once, synchronously, right after load, with no dangling callback to guard against — the leak described in (3) doesn't exist in this form, because Suspense discards the whole boundary on unmount instead of leaving a promise in flight.

The eight input listeners stay outside <Canvas> — they bind to window, not to any mesh, and R3F's per-object onWheel/onPointerMove props are pointer events scoped to a hovered object, which is not what a page-wide drag-to-scroll needs. Keep them as plain window.addEventListener calls in the wrapping component's own effect, and bridge their output into the useFrame callback with a single ref object (holding scrollPosition, scrollTarget, scrollMomentum, distortionAmount, distortionTarget and scrollDirection) created in the parent and passed as a prop to the child mounted inside <Canvas> — the closures over local let variables that mount uses today don't cross the boundary between the listener effect and the Canvas subtree, so this ref is what replaces them. Drive the title and counter the same way: keep the two <p> elements as plain DOM siblings of <Canvas>, not drei's <Html>, which would mount them into the WebGL tree for no reason, and write titleRef.current.textContent / counterRef.current.textContent straight from inside useFrame when the closest slide index changes, instead of calling setState — the update happens outside React's own render cycle, and two text nodes that never need reconciliation don't need to go through it either.

A poster is still mandatory here even though the ten planes are visible from the very first frame as flat grey placeholders — the gap a poster covers is the one *before* that: bundle parse, GPU context creation, and the ten useTexture suspensions all block the first real <Canvas> paint. Render a static image of the column at rest in the same box, sized to the section, and swap it out on <Canvas>'s onCreated, not once all ten textures resolve — the grey-then-fade-in sequence is this component's own intended look, not something the poster needs to hide.

Skip drei's Environment regardless of preset temptation: every material here is MeshBasicMaterial, which ignores lights and environment maps by construction, so there is nothing for a preset to light. A preset would add only the third-party CDN dependency baked into drei, and its fail-closed unlit result when that host is unreachable, for zero visual gain on a scene that is already unlit by design.