All components

Ripple Displacement Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

Fullscreen Three.js WebGL slider where a click anywhere fires a radial ripple (custom GLSL shader, GSAP-tweened progress over ~3s) that displaces UVs and crossfades to the next image along the expanding wave front. SplitText simultaneously staggers the masked title chars and description lines out (y:-100%) and animates the incoming slide's text in, all overlaid with mix-blend-mode: difference.

How it's built

Categoryslider
Techgsap, three
GSAP pluginsSplitText
Complexitysection
Performance costheavy
Mobile-safedesktop-first

three-js webgl shader ripple displacement slider click splittext crossfade

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

Ripple Displacement Slider (WebGL radial-wave crossfade on click)

Goal

Build a full-screen slider that shows one large, boxed image at a time with an editorial title on the left and a short description on the right. The image is not a DOM <img> — it is rendered onto a Three.js shader plane. Clicking anywhere on the slider fires a radial ripple: a custom GLSL wave expands from the center of the image outward, displacing the UVs along its front (the pixels ripple like water) and crossfading to the next slide's texture right behind the advancing wave, with a subtle brightness boost riding the crest. That ripple is a single GSAP tween of one shader uniform (uProgress, 0 → maxCornerDistance over ~3s, power2.out). Simultaneously, SplitText slides the current slide's title characters and description lines out (y: -100%) and, once they clear, builds the next slide and slides its text in (y: 100%0%). The overlaid text uses mix-blend-mode: difference, so the white type inverts against whatever image / background sits behind it. The star of this piece is the ripple-crossfade shader + its GSAP-driven progress; the SplitText choreography is the supporting act.

Tech

  • Vanilla HTML / CSS / JS with ES module imports, bundled by Vite.
  • gsap (npm) plus the GSAP plugin SplitText (registered via

gsap.registerPlugin(SplitText)).

  • three (npm), imported import * as THREE from "three";.
  • No ScrollTrigger, no Lenis, no CustomEase. Nothing scrolls; the only trigger is a click.
  • Put the two shader source strings in a sibling module shaders.js

(export const vertexShader / export const fragmentShader) and the slide data in slides.js (export const slides). Import both into script.js.

  • Top-level await is used to preload all textures before building the scene, so script.js

must be an ES module (Vite handles this).

Data model (slides.js)

Export an array of 4 slide objects, each { title, description, image }. Use these fictional film-still entries (neutral demo copy, no real brands):

export const slides = [
  { title: "Blackwater '91",
    description: "Flickering lanterns and twisted masks welcome unwanted visitors into a strange celebration beyond the forest trail.",
    image: "/path/slider-img-1.jpg" },
  { title: "Crimson Theory",
    description: "A mysterious performer slowly loses reality beneath violent lights and unsettling mirrored reflections inside an empty theater.",
    image: "/path/slider-img-2.jpg" },
  { title: "Tape Delay Archives",
    description: "Stacks of dusty videotapes and glowing static fill the room during another endless night without a single moment of sleep.",
    image: "/path/slider-img-3.jpg" },
  { title: "Exit 14 Westbound",
    description: "Heavy rain crashes against the windshield as terrified passengers race through midnight highways without knowing who follows.",
    image: "/path/slider-img-4.jpg" },
];

Layout / HTML

Minimal DOM. The renderer's <canvas> is prepended into .slider by JS; only the *current* slide's text overlay lives in the DOM at any moment (JS removes and rebuilds it per transition).

<div class="slider">
  <div class="slide-content">
    <div class="slide-title"><h1>Blackwater '91</h1></div>
    <div class="slide-description">
      <p>Flickering lanterns and twisted masks welcome unwanted visitors into a strange celebration beyond the forest trail.</p>
    </div>
  </div>
</div>
<script type="module" src="./script.js"></script>
  • The initial .slide-content (slide 0's title + description) is hardcoded in HTML; every

subsequent slide's .slide-content is created in JS from the slides data.

  • The WebGL <canvas> is slider.prepend(renderer.domElement), so it sits behind the text

overlay.

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box; }

  • Font: body { font-family: "PP Neue Montreal", sans-serif; } — a clean neutral grotesque

sans (use any Neue-Montreal-style face or a system sans fallback). Everything is weight 500.

  • h1 — `font-size: clamp(2rem, 4vw, 6rem); font-weight: 500; letter-spacing: -2%;

line-height: 1.25;`

  • pfont-weight: 500;
  • .sliderposition: fixed; width: 100%; height: 100svh; background: #e0ddcf; overflow: hidden;

A warm cream/bone backdrop that shows through wherever the shader renders transparent (i.e. the letterbox around the centered image box on desktop).

  • .slider canvasdisplay: block; width: 100%; height: 100%;
  • .slide-content — `position: absolute; top: 0; left: 0; width: 100%; height: 100%;

mix-blend-mode: difference; user-select: none; pointer-events: none; z-index: 2; The pointer-events: none lets the click fall through to .slider; the mix-blend-mode: difference` is what makes the white text invert against the image and the cream background — non-optional to the look.

  • .slide-title — `position: absolute; top: 50%; left: 3rem; transform: translate(0%, -50%);

width: max-content; color: #fff;` (title pinned to the left edge, vertically centered).

  • .slide-description — `position: absolute; top: 50%; right: 3rem;

transform: translate(0%, -50%); width: 15%; min-width: 250px; color: #fff; display: flex; flex-direction: column; gap: 2rem; z-index: 2;` (narrow column pinned to the right edge).

  • .char, .linedisplay: inline-block; will-change: transform; position: relative;

(SplitText-generated pieces; inline-block so y% transforms work, sitting inside their clipping masks).

  • Responsive @media (max-width: 1000px).slide-title re-centers

(top:50%; left:50%; transform: translate(-50%,-50%)); .slide-description becomes a centered bottom caption (width:75%; text-align:center; top:unset; bottom:5%; left:50%; transform: translate(-50%,-50%)).

The star effect — Three.js ripple-crossfade shader + GSAP progress (be exact)

This is a near-verbatim port. Reproduce the constants, the uniforms, the two shaders, and the GSAP tween exactly.

Renderer, scene & camera
  • const scene = new THREE.Scene();
  • Orthographic camera framing a 1×1 world centered on origin:

const camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.01, 10); camera.position.z = 1;

  • const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setClearColor(0x000000, 0); (transparent clear → the cream .slider background shows through). Then slider.prepend(renderer.domElement);

  • A single full-frame plane: new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material) exactly

fills the [-0.5, 0.5] frustum. scene.add(plane).

Texture preload (top-level await)

Load all 4 slide images sequentially with THREE.TextureLoader, awaiting each, into a textures[] array. For each: minFilter = magFilter = THREE.LinearFilter; wrapS = wrapT = THREE.ClampToEdgeWrapping;

const textureLoader = new THREE.TextureLoader();
const textures = [];
for (const slide of slides) {
  const texture = await new Promise((resolve) => textureLoader.load(slide.image, resolve));
  texture.minFilter = THREE.LinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  textures.push(texture);
}
Config constants (use these exact values)
const rippleConfig = {
  waveFreq: 25.0,        // sine frequency of the ripple oscillation
  wavePow: 0.035,        // UV displacement amplitude
  waveWidth: 0.5,        // width of the active wave band (also added to endValue)
  falloff: 10.0,         // exponential decay of the wave with age
  boostStrength: 0.5,    // brightness boost on the crest
  crossfadeWidth: 0.05,  // smoothstep width of the current→next blend behind the front
  duration: 3.0,         // GSAP tween seconds (1.5 on mobile)
  endValue: 1.0,         // recomputed on resize = maxCornerDist + waveWidth
  ease: "power2.out",
};
Uniforms
const uniforms = {
  uTexCurrent:     { value: textures[0] },
  uTexNext:        { value: textures[1] },
  uProgress:       { value: 0.0 },
  uResolution:     { value: new THREE.Vector2() },
  uImageRes:       { value: new THREE.Vector2(1920, 1280) },   // source image aspect, 3:2
  uWaveFreq:       { value: rippleConfig.waveFreq },
  uWavePow:        { value: rippleConfig.wavePow },
  uWaveWidth:      { value: rippleConfig.waveWidth },
  uFalloff:        { value: rippleConfig.falloff },
  uBoostStrength:  { value: rippleConfig.boostStrength },
  uCrossfadeWidth: { value: rippleConfig.crossfadeWidth },
  uMobile:         { value: window.innerWidth <= 1000 ? 1.0 : 0.0 },
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, transparent: true });
Vertex shader (shaders.js)
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Fragment shader (shaders.js) — reproduce verbatim

The wave lives in an aspect-corrected coordinate space (y scaled by aspect = uResolution.y / uResolution.x) so the ripple stays circular on any viewport. It expands from screen center; only the band where -waveWidth < (dist - progress) < 0 (i.e. just *inside* the front) ripples and boosts; blend (a smoothstep on how far behind the front you are) fades current→next. A boxMin/boxMax letterbox — a centered box on desktop (0.25..0.75 x, 0.175..0.825 y) that expands to full-bleed on mobile (0..1) — cover-fits the image and renders everything outside the box fully transparent.

uniform sampler2D uTexCurrent;
uniform sampler2D uTexNext;
uniform float uProgress;
uniform vec2 uResolution;
uniform vec2 uImageRes;
uniform float uWaveFreq;
uniform float uWavePow;
uniform float uWaveWidth;
uniform float uFalloff;
uniform float uBoostStrength;
uniform float uCrossfadeWidth;
uniform float uMobile;

varying vec2 vUv;

vec2 getImageUv(vec2 uv, vec2 screenRes, vec2 imgRes, vec2 boxMin, vec2 boxMax) {
  vec2 boxUv = (uv - boxMin) / (boxMax - boxMin);
  vec2 boxSize = (boxMax - boxMin) * screenRes;
  float boxAspect = boxSize.x / boxSize.y;
  float imgAspect = imgRes.x / imgRes.y;
  vec2 scale = vec2(1.0);
  if (boxAspect > imgAspect) {
    scale.y = imgAspect / boxAspect;
  } else {
    scale.x = boxAspect / imgAspect;
  }
  return (boxUv - 0.5) * scale + 0.5;
}

bool isInsideBox(vec2 uv, vec2 boxMin, vec2 boxMax) {
  return uv.x >= boxMin.x && uv.x <= boxMax.x && uv.y >= boxMin.y && uv.y <= boxMax.y;
}

void main() {
  vec2 boxMin = mix(vec2(0.25, 0.175), vec2(0.0), uMobile);
  vec2 boxMax = mix(vec2(0.75, 0.825), vec2(1.0), uMobile);

  float aspectRatio = uResolution.y / uResolution.x;

  vec2 coord  = vec2(vUv.x, vUv.y * aspectRatio);
  vec2 center = vec2(0.5, 0.5 * aspectRatio);

  float dist = distance(coord, center);
  float time = uProgress;

  vec2  displaced  = coord;
  float brightness = 0.0;
  float blend      = 0.0;

  if (time > 0.001) {
    float trailing = dist - time;

    if (trailing < uWaveWidth && trailing < 0.0) {
      float age   = -trailing;
      float decay = exp(-age * uFalloff);
      float wave  = sin(age * uWaveFreq) * decay;

      vec2 direction = normalize(coord - center);
      displaced += direction * wave * uWavePow;

      brightness = abs(wave) * uBoostStrength * decay;
    }

    blend = smoothstep(0.0, uCrossfadeWidth, -trailing);
  }

  vec2 finalUv = vec2(displaced.x, displaced.y / aspectRatio);
  vec2 imageUv = getImageUv(finalUv, uResolution, uImageRes, boxMin, boxMax);

  vec4 currentColor = texture2D(uTexCurrent, imageUv);
  vec4 nextColor    = texture2D(uTexNext, imageUv);

  vec4 color = mix(currentColor, nextColor, blend);
  color.rgb += color.rgb * brightness;

  if (!isInsideBox(finalUv, boxMin, boxMax)) {
    color = vec4(0.0);
  }

  gl_FragColor = color;
}

Key behavior to preserve: at uProgress = 0 nothing ripples and blend = 0, so the plane shows uTexCurrent cover-fit inside the box, transparent outside. As uProgress grows, the front sweeps outward; the thin band behind it ripples (sine × exp decay displacement) and brightens, and everything the front has already passed shows uTexNext.

Resize helper (recomputes the tween target)
function getMaxCornerDist() {
  const ratio = window.innerHeight / window.innerWidth;
  const cx = 0.5, cy = 0.5 * ratio;
  return Math.sqrt(cx * cx + cy * cy);   // distance center→corner in aspect-corrected space
}
function handleResize() {
  const width = slider.clientWidth, height = slider.clientHeight;
  renderer.setSize(width, height);
  uniforms.uResolution.value.set(width, height);
  uniforms.uMobile.value = window.innerWidth <= 1000 ? 1.0 : 0.0;
  rippleConfig.endValue = getMaxCornerDist() + rippleConfig.waveWidth;  // front must clear the corners
  rippleConfig.duration = window.innerWidth <= 1000 ? 1.5 : 3.0;        // faster on mobile
}
window.addEventListener("resize", handleResize);
handleResize();  // run once at startup

endValue is the progress needed for the wave to reach the farthest corner plus waveWidth, so the ripple + crossfade fully complete edge-to-edge.

The GSAP ripple tween (fires on transition)

A single gsap.to on the uProgress uniform value:

rippleTween = gsap.to(uniforms.uProgress, {
  value: rippleConfig.endValue,
  duration: rippleConfig.duration,   // 3.0 desktop / 1.5 mobile
  ease: rippleConfig.ease,           // "power2.out"
  delay: 0.3,
  onUpdate() {
    // unlock re-clicking early, once the wave has covered most of the frame
    if (!clickUnlocked && uniforms.uProgress.value > 0.7) {
      clickUnlocked = true;
      currentIndex = nextIndex;
      isTransitioning = false;
    }
  },
  onComplete() {
    uniforms.uTexCurrent.value = textures[currentIndex]; // promote next → current
    uniforms.uProgress.value = 0.0;                      // reset the wave
    rippleTween = null;
    if (!clickUnlocked) { currentIndex = nextIndex; isTransitioning = false; }
  },
});

SplitText choreography (title chars + description lines)

Splitters
  • Title: `SplitText.create(h1, { type: "words, chars", mask: "chars", wordsClass: "word",

charsClass: "char" }) — masked characters (each char clipped by its own overflow box so the y% slide reads as a reveal). Operate on split.chars`.

  • Description: for each .slide-description p,

SplitText.create(p, { type: "lines", mask: "lines", linesClass: "line" }), and collect all split.lines into one array.

Initial load reveal (runs once at startup)

Split the hardcoded slide-0 content, then two independent fromTo tweens (masked lines & chars rise from below):

gsap.fromTo(initialTitle.chars, { y: "100%" },
  { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out" });
gsap.fromTo(initialLines, { y: "100%" },
  { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out", delay: 0.2 });
Exit timeline (animateTextOut, current slide leaving)

Split the current content again, then a timeline that lifts everything up out of its mask:

const tl = gsap.timeline();
tl.to(titleSplit.chars, { y: "-100%", duration: 0.6, stagger: 0.02, ease: "power2.inOut" });      // at 0
tl.to(lines,            { y: "-100%", duration: 0.6, stagger: 0.02, ease: "power2.inOut" }, 0.1);  // absolute t = 0.1
return tl;   // the caller awaits tl.then(...)
Enter timeline (animateTextIn, next slide arriving)

Pre-set the freshly built next content offscreen-below and make the container visible, then rise in:

gsap.set([chars, lines], { y: "100%" });
gsap.set(container, { opacity: 1 });   // buildSlideContent starts at opacity 0
return gsap.timeline()
  .to(chars, { y: "0%", duration: 0.5, stagger: 0.02, ease: "power2.inOut" })          // at 0
  .to(lines, { y: "0%", duration: 0.5, stagger: 0.05, ease: "power2.out" }, 0.1);       // absolute t = 0.1

buildSlideContent(slide) creates a div.slide-content with inline opacity: 0 and inner <div class="slide-title"><h1>${title}</h1></div> + <div class="slide-description"><p>${description}</p></div>.

The transition orchestration (click handler)

slider.addEventListener("click", transition). Module-level state: currentIndex = 0, isTransitioning = false, rippleTween = null.

function transition() {
  if (isTransitioning) return;
  isTransitioning = true;

  if (rippleTween) { rippleTween.kill(); uniforms.uProgress.value = 0.0; rippleTween = null; }

  const nextIndex = (currentIndex + 1) % slides.length;   // cycles 0→1→2→3→0…
  const currentSlide = document.querySelector(".slide-content");

  const exitTimeline = animateTextOut(currentSlide);      // text flies up

  uniforms.uTexCurrent.value = textures[currentIndex];
  uniforms.uTexNext.value    = textures[nextIndex];
  uniforms.uProgress.value   = 0.0;

  let clickUnlocked = false;
  rippleTween = gsap.to(uniforms.uProgress, { /* …the ripple tween above… */ });

  exitTimeline.then(() => {
    currentSlide.remove();                                // drop old DOM overlay
    const nextSlide = buildSlideContent(slides[nextIndex]);
    slider.appendChild(nextSlide);
    requestAnimationFrame(() => animateTextIn(nextSlide)); // text flies in next frame
  });
}

So a click launches two parallel things: the ~3s ripple/crossfade (delayed 0.3s) on the WebGL plane, and the text out→in swap gated by the exit timeline finishing. The uProgress > 0.7 unlock lets an impatient user click again before the wave fully lands.

Render loop
function render() {
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}
render();

A plain rAF render loop — no delta time, no easing here; all animation comes from GSAP mutating the uniforms and the SplitText transforms.

Assets / images

  • 4 interchangeable slide textures, all the same role (one is shown at a time; consecutive

pairs crossfade during the ripple). Landscape 3:2 (~1920×1280), one shared aspect so the cover-fit box frames each identically. Moody, cinematic film-still photographs, all dominated by deep reds and near-black shadows with strong tonal contrast (the ripple displacement and crossfade read best on these dark, high-contrast, warm-red frames). What's actually shown:

  1. a red-lit interior: a jack-o'-lantern pumpkin-headed figure face-to-face with a red-haired

woman seen in profile, against a black hallway with a distant red glow (dominant: warm red / orange pumpkin, black);

  1. a red-toned close-up of a blonde woman screaming, lit hard against a dark red velvet curtain

(dominant: saturated red, black);

  1. a dim, dark 1980s living room where a figure leans over a boxy old TV glowing red-pink static,

furniture barely readable in the shadows (dominant: near-black with a red screen glow);

  1. a red-lit shot through a rain-beaded car windshield at night: a frightened man and woman in the

front seats, blurred red lights behind them (dominant: red, black, wet-glass speckle). Exact aspect isn't critical — the shader cover-fits into the box — but 3:2 matches the uImageRes of 1920×1280. No logos or baked-in text.

Behavior notes

  • Click-driven on the whole .slider. No autoplay, no scroll, no hover — the slider is static

until a click, then advances one slide (wrapping after the 4th).

  • On desktop the image is a centered box with cream letterbox margins; on mobile

(≤ 1000px) it goes full-bleed and the ripple runs faster (1.5s vs 3s).

  • The ripple must reach past the far corners: endValue = maxCornerDist + waveWidth, recomputed on

every resize alongside uResolution, uMobile, and duration.

  • mix-blend-mode: difference on the text overlay is essential — the white type must invert

against the image and the cream background, not sit as flat white.

  • Re-clicking is unlocked at uProgress > 0.7 (or on tween complete), so rapid clicks queue

smoothly; an in-flight rippleTween is killed and uProgress reset before starting the next.

  • pixelRatio capped at 2. No reduced-motion branch in the original. Requires WebGL; top-level

await means the scene only builds after all 4 textures load.