All components

Cielrose Inspired ThreeJS Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A wheel-driven WebGL image slider built with Three.js: scrolling drives a custom shader that vertically wipes between the current and next texture while a distortion/scale uniform bends and swells the plane, then a smooth lerp snaps to the nearest image and slides the project title back into view. Animation is hand-rolled in a requestAnimationFrame loop plus CSS transitions (no GSAP).

How it's built

Categoryslider
Techthree
Complexitypage
Performance costheavy
Mobile-safedesktop-first

three.js webgl shader slider scroll chromatic distortion experimental 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

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 pinned top:0, footer pinned bottom:0.
  • .links, .socials: display:flex; gap:2em;. Also nav > *, .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 the transform value.

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. Append renderer.domElement to .container.
  • Plane dimensions from the camera frustum: viewportHeight = 2 * tan(fovRadians/2) * camera.position.z, viewportWidth = viewportHeight * camera.aspect. Then widthFactor = 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; set minFilter and magFilter to THREE.LinearFilter; mark each needsUpdate = true to preload.
  • THREE.ShaderMaterial with side: THREE.DoubleSide and uniforms:
  • uScrollIntensity (float, scroll velocity −1..1)
  • uScrollPosition (float, fractional slide progress 0..1)
  • uCurrentTexture, uNextTexture (samplers, init textures[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)
  1. scrollIntensity += (targetScrollIntensity - scrollIntensity) * 0.5 → write to uScrollIntensity.
  2. scrollPosition += (targetScrollPosition - scrollPosition) * 0.05 (heavy glide).
  3. uScrollPosition = isStable ? 0 : positiveFract(scrollPosition).
  4. updateTextureIndices().
  5. Scale breathing: baseScale = 1.0, scaleIntensity = 0.1. If scrollIntensity > 0: scale = 1 + scrollIntensity * 0.1; else scale = 1 - |scrollIntensity| * 0.1. Apply plane.scale.set(scale, scale, 1) — the plane grows up to 10% scrolling down, shrinks up to 10% scrolling up.
  6. Velocity decay: targetScrollIntensity *= 0.98 — intensity (bulge + scale) relaxes back to 0 on its own after input stops.
  7. Snap logic: scrollDelta = |targetScrollPosition - scrollPosition|.
  8. If scrollDelta < 0.001 and isMoving && !isSnappingsnapToNearestImage(): set targetScrollPosition = Math.round(scrollPosition), compute stable indices from that rounded position, store currentProjectIndex, call showTitle(). The 0.05 lerp then glides the seam to the nearest whole image.
  9. If scrollDelta < 0.0001 → if not already stable: isStable = true, scrollPosition = Math.round(scrollPosition), targetScrollPosition = scrollPosition. Then isMoving = false, isSnapping = false.
  10. 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, set projectTitle.style.transform = "translateY(20px)" — the text drops below the 16px mask and disappears. A 500ms setTimeout flips titleHidden = true.
  • showTitle() (called by the snap): if hidden and not animating, first swap textContent to slides[currentProjectIndex].title and the link href to the slide's URL, then set transform = "translateY(0px)" — the new title rises back into the mask. 500ms timeout resets the guard flags.
  • Both use the titleAnimating flag to prevent overlap; the motion itself comes from the CSS transition: 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:

  1. Slide 1 "Chromatic Loopscape" — interlocking rounded U-shaped tubes with iridescent chromatic edges on black.
  2. Slide 2 "Solar Bloom" — a soft glossy translucent orb floating over a warm red-orange gradient haze.
  3. Slide 3 "Neon Handscape" — two symmetrical stylized 3D hands with long fingers in purple/orange thermal tones on black.
  4. Slide 4 "Echo Discs" — stacked glossy elliptical discs orbited by a thin ring over a saturated rainbow gradient.
  5. Slide 5 "Void Gaze" — two glowing orbs inside a dark almond-shaped void framed by purple/orange bands, like an eye.
  6. Slide 6 "Gravity Sync" — a floating ringed planet with smaller spheres over a purple-to-orange gradient.
  7. Slide 7 "Heat Core" — a cross-shaped cluster of glossy metallic cylinders with orange/purple iridescent reflections on black.

Name them sequentially (img1.jpgimg7.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 uScrollPosition forced 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.