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.