Three.js Wheel-Driven Shader Slider
Goal
Build a full-viewport WebGL image slider driven by the mouse wheel. A single 16:9 plane floats in the center of the screen over a soft grey-to-white gradient page. Scrolling wheels through an endless loop of 7 images: a custom fragment shader performs a vertical filmstrip wipe (the next image slides up from the bottom edge while the current one slides out the top), a vertex shader bulges the plane toward the camera proportionally to scroll velocity, and the whole plane swells/shrinks slightly with scroll intensity. When scrolling stops, a lerp snaps to the nearest whole image and the project title (clipped inside a 16px-tall mask) slides back up into view with the new slide's name. Everything is hand-rolled in a requestAnimationFrame loop plus one CSS transition — no GSAP, no ScrollTrigger, no page scroll.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use three (npm) only:
import * as THREE from "three";
No GSAP, no Lenis. Structure the JS in three modules: script.js (main), shaders.js (exports vertexShader and fragmentShader GLSL strings), and slides.js (exports a slides array of { title, url, image }).
Layout / HTML
<body>
<nav>
<div class="logo"><a href="#">Motionprompts</a></div>
<div class="links">
<a href="#">About</a>
<a href="#">Contact</a>
<div class="socials">
<a href="#">FB</a><a href="#">IG</a><a href="#">YT</a>
</div>
</div>
</nav>
<footer>
<p>Experiment 444</p>
<p>Scroll to explore</p>
</footer>
<div class="gradient-bg"></div>
<div class="container">
<div class="project-title-container">
<a href="#" id="project-link">
<div class="project-title-mask">
<p id="project-title">Title 1</p>
</div>
</a>
</div>
</div>
<script type="module" src="./script.js"></script>
</body>
The Three.js <canvas> is appended by JS into .container. The title sits in a fixed, centered overlay exactly matching the plane's footprint.
Styling
Import Google font Roboto Mono (@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap")). Global reset * { margin:0; padding:0; box-sizing:border-box; }.
a, p:display:block; text-decoration:none; text-transform:uppercase; color:#000; font-family:"Roboto Mono"; font-size:12px; font-weight:400;— everything on the page is tiny uppercase mono text.nav, footer:position:absolute; left:0; width:100vw; padding:1em; display:flex; justify-content:space-between; gap:2em; z-index:1;— nav pinnedtop:0, footer pinnedbottom:0..links, .socials:display:flex; gap:2em;. Alsonav > *, .links a { flex:1; }so the nav spreads evenly across the top..gradient-bg:position:absolute; bottom:0; width:100vw; height:100svh; background:linear-gradient(0deg, rgba(204,204,204,1) 0%, rgba(255,255,255,1) 100%); z-index:0;— light grey at the bottom fading to white at the top. The WebGL canvas is transparent, so this gradient is the page background..container:position:relative; width:100vw; height:100svh; overflow:hidden; z-index:2;— hosts the canvas..project-title-container:position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; aspect-ratio:16/9;— the exact same box as the WebGL plane (50% of viewport width, 16:9), so the title is vertically centered over the image.#project-link:width:100%; height:100%; display:flex; align-items:center; color:#fff;..project-title-mask:position:relative; width:100%; height:16px; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); overflow:hidden;— a 16px-tall clipping window; the title hides by translating just past it.#project-title:display:block; position:relative; transform:translateY(0px); color:#fff; text-align:center; text-transform:uppercase; font-family:"Roboto Mono"; font-size:13px; line-height:1; transition: transform 0.5s ease-in-out;— this CSS transition is the only tweened animation; JS just flips thetransformvalue.
The effect (be exhaustive — hand-rolled rAF + shaders, no GSAP)
Slides data (slides.js)
7 entries, each { title, url, image }. Titles: "Chromatic Loopscape", "Solar Bloom", "Neon Handscape", "Echo Discs", "Void Gaze", "Gravity Sync", "Heat Core". URLs are placeholder links (e.g. https://example.com/alpha … /eta). Images are the 7 texture files. On load, set #project-title text and #project-link href from slides[0].
State variables (exact values matter)
let scrollIntensity = 0, targetScrollIntensity = 0;
const maxScrollIntensity = 1.0;
const scrollSmoothness = 0.5; // fast lerp — intensity reacts snappily
let scrollPosition = 0, targetScrollPosition = 0;
const scrollPositionSmoothness = 0.05; // slow lerp — position glides with heavy inertia
let isMoving = false;
const movementThreshold = 0.001;
let isSnapping = false;
let stableCurrentIndex = 0, stableNextIndex = 1, isStable = false;
let titleHidden = false, titleAnimating = false, currentProjectIndex = 0;
Scene setup
THREE.Scene(),THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 1000),camera.position.z = 5.THREE.WebGLRenderer({ antialias: true });setSize(innerWidth, innerHeight);setPixelRatio(Math.min(devicePixelRatio, 2));setClearColor(0xffffff, 0)— alpha 0 so the CSS gradient shows through. Appendrenderer.domElementto.container.- Plane dimensions from the camera frustum:
viewportHeight = 2 * tan(fovRadians/2) * camera.position.z,viewportWidth = viewportHeight * camera.aspect. ThenwidthFactor = innerWidth < 900 ? 0.9 : 0.5;planeWidth = viewportWidth * widthFactor;planeHeight = planeWidth * (9/16). So the plane occupies 50% of the viewport width on desktop, 90% under 900px, always 16:9 — matching.project-title-container. THREE.PlaneGeometry(width, height, 32, 32)— the 32×32 segments are required for the vertex-shader bend.- Load all 7 textures with
THREE.TextureLoader; setminFilterandmagFiltertoTHREE.LinearFilter; mark eachneedsUpdate = trueto preload. THREE.ShaderMaterialwithside: THREE.DoubleSideand uniforms:uScrollIntensity(float, scroll velocity −1..1)uScrollPosition(float, fractional slide progress 0..1)uCurrentTexture,uNextTexture(samplers, inittextures[0]/textures[1])
Vertex shader (velocity bulge)
uniform float uScrollIntensity;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 pos = position;
float sideDistortion = sin(uv.y * 3.14159) * uScrollIntensity * 0.5;
float topBottomDistortion = sin(uv.x * 3.14159) * uScrollIntensity * 0.2;
pos.z += sideDistortion + topBottomDistortion;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
Both sin(uv * π) terms peak at the plane's center and vanish at the edges, so the plane bows toward the camera like a sail (amplitude 0.5 along the vertical axis, 0.2 along the horizontal). Sign follows scroll direction: scroll down bulges forward, scroll up bows backward.
Fragment shader (vertical filmstrip wipe)
uniform sampler2D uCurrentTexture;
uniform sampler2D uNextTexture;
uniform float uScrollPosition;
varying vec2 vUv;
void main() {
float normalizedPosition = fract(uScrollPosition);
vec2 currentUv = vec2(vUv.x, mod(vUv.y - normalizedPosition, 1.0));
vec2 nextUv = vec2(vUv.x, mod(vUv.y + 1.0 - normalizedPosition, 1.0));
if (vUv.y < normalizedPosition) {
gl_FragColor = texture2D(uNextTexture, nextUv);
} else {
gl_FragColor = texture2D(uCurrentTexture, currentUv);
}
}
As uScrollPosition goes 0 → 1, a horizontal seam rises from the bottom edge: below the seam the next texture shows, above it the current one, and both textures' uv.y are offset by the same amount so the pair scrolls together like one continuous vertical filmstrip.
Texture index bookkeeping
determineTextureIndices(position) — with 7 slides:
baseIndex = Math.floor(position % totalImages); if negative, wrap with(totalImages + baseIndex) % totalImages— supports scrolling backwards past zero infinitely.nextIndex = (currentIndex + 1) % totalImages.normalizedPosition = position % 1, +1 if negative.
Every frame, updateTextureIndices() assigns uCurrentTexture/uNextTexture from these indices — unless isStable is true, in which case it uses the frozen stableCurrentIndex/stableNextIndex (prevents a 1-frame texture pop at rest).
Wheel input (the only trigger)
window.addEventListener("wheel", (event) => {
event.preventDefault(); // the page never scrolls
isSnapping = false; isStable = false;
hideTitle();
targetScrollIntensity += event.deltaY * 0.001;
targetScrollIntensity = Math.max(-1, Math.min(1, targetScrollIntensity)); // clamp ±1
targetScrollPosition += event.deltaY * 0.001;
isMoving = true;
}, { passive: false });
The rAF loop (every frame, in this order)
scrollIntensity += (targetScrollIntensity - scrollIntensity) * 0.5→ write touScrollIntensity.scrollPosition += (targetScrollPosition - scrollPosition) * 0.05(heavy glide).uScrollPosition = isStable ? 0 : positiveFract(scrollPosition).updateTextureIndices().- Scale breathing:
baseScale = 1.0,scaleIntensity = 0.1. IfscrollIntensity > 0:scale = 1 + scrollIntensity * 0.1; elsescale = 1 - |scrollIntensity| * 0.1. Applyplane.scale.set(scale, scale, 1)— the plane grows up to 10% scrolling down, shrinks up to 10% scrolling up. - Velocity decay:
targetScrollIntensity *= 0.98— intensity (bulge + scale) relaxes back to 0 on its own after input stops. - Snap logic:
scrollDelta = |targetScrollPosition - scrollPosition|. - If
scrollDelta < 0.001andisMoving && !isSnapping→snapToNearestImage(): settargetScrollPosition = Math.round(scrollPosition), compute stable indices from that rounded position, storecurrentProjectIndex, callshowTitle(). The 0.05 lerp then glides the seam to the nearest whole image. - If
scrollDelta < 0.0001→ if not already stable:isStable = true,scrollPosition = Math.round(scrollPosition),targetScrollPosition = scrollPosition. ThenisMoving = false,isSnapping = false. renderer.render(scene, camera).
Title hide/show (CSS transition, 0.5s ease-in-out)
hideTitle()(on first wheel event): if not hidden and not animating, setprojectTitle.style.transform = "translateY(20px)"— the text drops below the 16px mask and disappears. A 500mssetTimeoutflipstitleHidden = true.showTitle()(called by the snap): if hidden and not animating, first swaptextContenttoslides[currentProjectIndex].titleand the linkhrefto the slide's URL, then settransform = "translateY(0px)"— the new title rises back into the mask. 500ms timeout resets the guard flags.- Both use the
titleAnimatingflag to prevent overlap; the motion itself comes from the CSStransition: transform 0.5s ease-in-out.
Resize
On resize: update camera.aspect + updateProjectionMatrix(), renderer.setSize + setPixelRatio(min(dpr, 2)), recompute plane dimensions (with the < 900px → 0.9 width factor), dispose() the old geometry, and assign a fresh PlaneGeometry(w, h, 32, 32).
Assets / images
7 slide textures, displayed in a 16:9 landscape frame (any generous resolution, e.g. 1280×720+; they map edge-to-edge onto the plane). They should read as one cohesive set of dark, saturated, abstract 3D renders with iridescent / chromatic-aberration lighting — glossy surreal objects glowing against black or deep-gradient backgrounds. By role:
- Slide 1 "Chromatic Loopscape" — interlocking rounded U-shaped tubes with iridescent chromatic edges on black.
- Slide 2 "Solar Bloom" — a soft glossy translucent orb floating over a warm red-orange gradient haze.
- Slide 3 "Neon Handscape" — two symmetrical stylized 3D hands with long fingers in purple/orange thermal tones on black.
- Slide 4 "Echo Discs" — stacked glossy elliptical discs orbited by a thin ring over a saturated rainbow gradient.
- Slide 5 "Void Gaze" — two glowing orbs inside a dark almond-shaped void framed by purple/orange bands, like an eye.
- Slide 6 "Gravity Sync" — a floating ringed planet with smaller spheres over a purple-to-orange gradient.
- Slide 7 "Heat Core" — a cross-shaped cluster of glossy metallic cylinders with orange/purple iridescent reflections on black.
Name them sequentially (img1.jpg … img7.jpg) and reference them from slides.js.
Behavior notes
- Wheel/trackpad only — the wheel listener calls
preventDefault()(registered with{ passive: false }), so the document never scrolls; "Scroll to explore" refers to the slider itself. No click/drag/keyboard navigation. - Infinite in both directions: positive-modulo index math loops 7 → 1 forward and 1 → 7 backward with no seam.
- The slider always comes to rest exactly on a whole image (snap + stable-state clamp), with
uScrollPositionforced to 0 while stable. - Desktop-oriented; the only responsive concession is the plane width factor (0.5 → 0.9 below 900px viewport width) and full resize handling. No reduced-motion handling — motion is entirely user-driven.
- Canvas is transparent; the grey→white CSS gradient is the visible backdrop, and thin black mono type (nav/footer) frames the composition while the white title floats centered on the imagery.
Images
This component ships with 7 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/cielrose-threejs-slider-javascript/img1.jpg
https://motionprompts.dev/c/cielrose-threejs-slider-javascript/img2.jpg
https://motionprompts.dev/c/cielrose-threejs-slider-javascript/img3.jpg
https://motionprompts.dev/c/cielrose-threejs-slider-javascript/img4.jpg
https://motionprompts.dev/c/cielrose-threejs-slider-javascript/img5.jpg
https://motionprompts.dev/c/cielrose-threejs-slider-javascript/img6.jpg
… 1 more under https://motionprompts.dev/c/cielrose-threejs-slider-javascript/
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,--muted,--paper,--zinc,--steel. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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
*,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 scene, camera, renderer, the shader-driven plane and its seven textures, the wheel/touch listeners, and the animate() loop that drives all of it, and the function mount returns cancels the frame, removes all five window listeners, clears the two title timers, disposes the geometry, the material and every texture, force-loses the WebGL context, and restores the title's text, its inline style, and the link's href to whatever they were before mount touched them. 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 appends a second <canvas> into .container next to the first, binds a second set of wheel/touch listeners to the same window, and starts a second animate() loop that scales and re-textures a plane the first loop knows nothing about — two renderers racing to paint the same box, both reacting to the same wheel event. 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(Object.assign({}, DEFAULTS)) directly inside a useEffect with an empty dependency array, keeping its return value as the effect's own cleanup. A React component never needs the readyState guard — useEffect already runs after the DOM is committed, so the race that guard protects against (script parsed and run before the DOM it queries exists) cannot happen here.
*(2) Element lookups* — mount looks up .container, #project-title and #project-link against the document and already returns a no-op destroy if any of the three is missing; keep that guard. What is not safe unscoped is the lookup itself: give the component a root ref, render the container div and the title link inside it, and resolve the three nodes from that ref instead of the document. During the StrictMode remount two copies of this subtree exist for an instant, and an unscoped #project-title can bind to the copy already on its way out. Keep stashing titleOriginalText, titleOriginalStyle and linkOriginalHref before overwriting them, too — that triplet is what lets destroy hand the title node back exactly as it found it, which matters here because the title's text and href are driven imperatively rather than through props.
*(3) Cleanup* — two things in this effect outlive a naive return.
The animate() loop is the only thing that renders a frame: it lerps scrollIntensity and scrollPosition toward their targets, writes the two into the shader's uniforms, calls updateTextureIndices(), applies the scale-breathing to plane.scale, runs the snap check, and only then calls renderer.render, before scheduling its own next call. Keep the alive flag and the frame handle exactly as used: alive stops the body of the next already-scheduled call, and cancelAnimationFrame(frame) in the returned cleanup stops the call still queued. Drop either one and a StrictMode remount leaves the first loop's render() calls interleaved with the second's, each scaling and re-texturing a plane object the other loop never touches — the visible symptom is a slider whose zoom-breathing and filmstrip wipe both run at roughly double rate.
loadTextures guards the one asynchronous seam in this component: each of the seven TextureLoader.load() calls carries an onLoad and an onError that check destroyed before doing anything, because a texture requested at mount time decodes and lands on three's own schedule, which can be after that mount has already been torn down. Keep destroyed (or fold it into whatever cancellation flag the rest of the effect uses) and keep the check in both callbacks — removing it because "the cleanup already disposed everything" leaves a late onLoad free to call texture.dispose() on a texture a fresh remount may already be pointing a live uniform at, or, if the check is dropped from onError too, to keep logging into a component that no longer exists.
*(4) Rendering this in @react-three/fiber* — three 0.185, @react-three/fiber 9, drei 10.7, React 19.
<Canvas> replaces the new THREE.Scene() / PerspectiveCamera / WebGLRenderer block outright: match the camera's field of view, near/far planes and starting z with camera={{ fov: 75, near: 0.1, far: 1000, position: [0, 0, 5] }}, and carry the antialias: true and the transparent clear (setClearColor(0xffffff, 0)) into gl={{ antialias: true, alpha: true }} — the CSS gradient behind the canvas is the whole reason the clear is transparent, and a default <Canvas> opts into it already. calculatePlaneDimensions should not be ported as manual frustum trigonometry: useThree(({ viewport }) => viewport) already gives the visible width and height in world units at the plane's depth, so only the widthFactor breakpoint (the switch to the wider factor under 900px) still needs a manual viewport-width check, since that number is a CSS-style breakpoint R3F's viewport has no opinion about. PlaneGeometry becomes a declarative <planeGeometry args={[width, height, 32, 32]} /> re-created (new key, or a useMemo keyed on the computed width/height) whenever the viewport-driven dimensions change — R3F disposes the outgoing geometry for you when the element unmounts, so the manual plane.geometry.dispose() in onResize goes away with the listener itself. renderer.setPixelRatio becomes <Canvas dpr={[1, 2]}>.
TextureLoader.load(...) becomes drei's useTexture, called once with all seven URLs (useTexture(slides.map(s => s.image))) so Suspense holds the tree until every image has decoded, replacing the seven independent onLoad callbacks with one readiness gate. Keep determineTextureIndices and updateTextureIndices exactly as written — they stay pure index math over the loaded array, unaffected by how the array got loaded — and still assign the two texture uniforms by index from inside the frame loop rather than through props, since the current/next pair changes every frame while scrolling.
The animate() body becomes the callback passed to useFrame: everything between the intensity lerp and renderer.render moves in, unchanged in logic, except renderer.render(scene, camera) is dropped — <Canvas> renders the frame for you once useFrame returns. Keep scrollIntensity, scrollPosition, isStable, stableCurrentIndex/stableNextIndex and the title-related flags as refs, not useState: this loop runs every frame, and routing any of these through state re-renders the React tree at that same rate for values that only ever feed a ShaderMaterial uniform and a Mesh scale. Get the material by attaching a ref to a declarative <shaderMaterial args={[{ uniforms, vertexShader, fragmentShader }]} side={THREE.DoubleSide} /> and mutate materialRef.current.uniforms.uScrollIntensity.value from inside useFrame, the same way the vanilla loop mutates material.uniforms directly.
The wheel and touch listeners stay exactly what they are — page-level input, not pointer events on a mesh — so they stay on window, attached from a useEffect (inside or beside the Canvas-hosting component) that writes into the same refs useFrame reads, and torn down the same way destroy() already does. The title hide/show pair stays outside the Canvas entirely: it is a DOM overlay whose only motion is the existing CSS transition on transform, so port hideTitle/showTitle as a small piece of state (or the same imperative style-write) driven by the snap logic in useFrame, and keep the two 500ms timers in the same kind of tracked set the vanilla script already uses, cleared in this component's own cleanup.
A poster is mandatory even though nothing here is a .glb: with seven textures loading through Suspense, a cold visit shows nothing at all — not even the first frame — until all seven have decoded. Render slide one's image (img1.jpg, "Liquid Chrome") as a plain <img> filling the same 16:9 box the plane occupies, and swap it out only once the Canvas has actually painted a frame with the loaded textures, not the instant the component mounts.
Skip drei's Environment regardless of preset temptation: the ShaderMaterial here is fully custom and reads no scene lighting at all, so there is nothing for an environment map to feed. If a later variant of this slider swaps in a lit material, light it with explicit lights or a self-hosted HDRI passed to Environment — never a preset, which is fetched from a third-party CDN hard-coded into drei and leaves the scene unlit the moment that host is unreachable.