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 pluginSplitText(registered via
gsap.registerPlugin(SplitText)).
three(npm), importedimport * 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
awaitis used to preload all textures before building the scene, soscript.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>isslider.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;`
p—font-weight: 500;.slider—position: 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 canvas—display: 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, .line—display: 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-titlere-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:
- 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);
- a red-toned close-up of a blonde woman screaming, lit hard against a dark red velvet curtain
(dominant: saturated red, black);
- 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);
- 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: differenceon 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.
pixelRatiocapped at 2. No reduced-motion branch in the original. Requires WebGL; top-level
await means the scene only builds after all 4 textures load.
Images
This component ships with 4 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/ripple-displacement-slider/slider-img-1.jpg
https://motionprompts.dev/c/ripple-displacement-slider/slider-img-2.jpg
https://motionprompts.dev/c/ripple-displacement-slider/slider-img-3.jpg
https://motionprompts.dev/c/ripple-displacement-slider/slider-img-4.jpg
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—--paper,--crimson,--mono,--edge. 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. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two ripple tweens fighting over the same uProgress uniform, two render loops painting into two stacked canvases. The visible symptom is a doubled or stuttering ripple, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
(1) The entry point. The script guards its DOMContentLoaded listener behind a document.readyState check, then wraps that in a second branch that hands mount/DEFAULTS to window.MP.register when the catalogue's own editor runtime is present, falling back to a plain boot() otherwise. Neither survives the port: the readyState guard is dead weight under useEffect, which already runs after the DOM is committed, and window.MP will not exist in a React host at all. Drop both branches and the guard, and move the body of mount — from the config resolution (waveFreq, wavePow, falloff, crossfadeWidth, stagger, all clamped against DEFAULTS) down to where it builds its return value — directly inside a useEffect with an empty dependency array. mount()'s own return value is already a destroy() that unwinds canvas, textures, listeners and tweens in one call; that shape is not incidental to this component, it already is the effect-cleanup contract React asks for, arrived at independently, and it should be kept rather than restructured into something else.
(2) Element lookups. document.querySelector(".slider"), and inside it .slide-content, .slide-title h1 and .slide-description p, all assume this component owns the document. Give the wrapping element a root ref and scope every lookup to it instead of the bare document. The one lookup that needs more than scoping is .slide-content itself: the vanilla script treats the HTML-authored slide 0 as a piece of DOM it must literally splice back out on unmount (originalSlide / originalParent / originalNext, restored before anything else runs in the cleanup) because the very first transition calls .remove() on that exact node and nothing puts it back on its own. In React that restoration dance is a symptom of DOM built by hand — render slide 0 from the same slides array as every other slide (a slide-index ref or state value, not a hardcoded <h1>Blackwater '91</h1> sitting in JSX) and there is no original node to protect, because unmounting the component removes it the same way it removes everything else.
(3) Cleanup.
Wrap every tween and timeline this component creates — the initial reveal on initialTitle.chars and initialLines, the two timelines inside animateTextOut / animateTextIn, and the ripple tween on uniforms.uProgress — in a gsap.context scoped to the root ref, and revert that context in the cleanup:
useEffect(() => {
const ctx = gsap.context((self) => {
/* the initial reveal only; the transition handler is wired in below, once textures resolve */
}, rootRef);
return () => ctx.revert();
}, []);
The transition function is not created inside that synchronous factory, though — it only exists once loadTextures() resolves, after the effect has already returned. Registering it still has to go through the same context or its timelines survive the revert: call ctx.add(() => { /* transition(), the click listener, render-loop wiring */ }) from inside that .then(), using ctx rather than self — by the time an asynchronous continuation runs, ctx is fully initialized, so the temporal-dead-zone hazard only applies to the factory's own synchronous pass, not to code that runs after it. Do not, on the other hand, make the effect itself async to await the textures before returning a cleanup function. This component already carries the right discipline for that: loadTextures checks its destroyed flag after every texture resolves and disposes the one it just decoded instead of pushing it, and every async continuation inside start() (exitTimeline.then(...), the ripple tween's onComplete) bails out on that same flag. Carry that flag into the effect as its cancellation flag — rename it if you like — rather than inventing a second one.
The render loop (render() calling itself via requestAnimationFrame) and the one-shot requestAnimationFrame that defers animateTextIn by a frame after slider.appendChild(nextSlide) are two independent handles, tracked separately in the vanilla script (frame and the rafs Set) for exactly this reason. Cancel both in the cleanup — cancelling only the render loop and forgetting the deferred one leaves a callback that, after a StrictMode remount, calls animateTextIn against text belonging to the copy that is on its way out.
splitTitle and splitDescription run on every transition, including the first slide. The vanilla remember() helper only bothers tracking splits made on the original HTML-authored slide, because every other .slide-content is thrown away along with its splits when the slide is removed. Once slide markup comes from React instead of buildSlideContent's innerHTML string, every split is a "created" one in that same sense — track and revert all of them, most recent first, inside the same gsap.context cleanup, ordered after the tweens still referencing titleSplit.chars and the line spans have already been killed or reverted.
Three.js → R3F. The scene here is about as simple as R3F scenes get — one orthographic camera, one full-frame plane, one shader material — which makes it a good candidate for the declarative form rather than an argument for keeping the imperative one:
- Delete the
new THREE.Scene()/new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.01, 10)/
new THREE.WebGLRenderer(...) block and the slider.prepend(renderer.domElement) call. Describe the same frustum with an orthographic <Canvas> sized to match that camera and an alpha-enabled gl, and the geometry as a <mesh> holding a <planeGeometry> sized to one unit square and a <shaderMaterial> fed the existing uniforms, vertexShader and fragmentShader.
- Delete `function render() { renderer.render(scene, camera); frame =
requestAnimationFrame(render); } outright rather than porting it into useFrame. <Canvas> already repaints every frame by default, and everything this loop does — redraw after GSAP wrote a new uniforms.uProgress.value — happens for free under that default; adding a useFrame` that also forces a render would be a second, redundant path drawing the same scene.
uResolutionanduMobileare not covered by "resize is already handled" — that guarantee
is about the renderer's own canvas size, which <Canvas> does take care of. This particular shader also folds uResolution into its aspect-correction math and reads uMobile to switch the centered-box letterbox to full-bleed, and neither uniform updates itself. Read the canvas size from useThree's size, and push it into the material's uResolution and (via the same breakpoint the vanilla handleResize uses) uMobile uniforms, plus the ripple's mobile/desktop duration and its corner-distance target, inside a small effect keyed on that size.
THREE.TextureLoaderloaded the four slide images sequentially with a hand-rolledawaitloop
so the scene could index into textures[0] / textures[1]; drei's useTexture given the same four URLs replaces that loop and integrates with Suspense. The sequential-with-early-abort behavior this component relies on today (if (destroyed) { texture.dispose(); return false; }) goes away with it — it is the Suspense boundary, not a destroyed check, that now stands between a late-resolving load and an unmounted tree.
- **A static poster is mandatory here specifically because the load is sequential and
network-bound**: four images must resolve, in order, before the scene has anything to show, so a cold visit leaves .slider empty for however long that takes. Render slides[0].image as a plain image in the same box the canvas will occupy, and remove it once the texture set is ready.
- Do not reach for drei's
Environmentwith apreset. ThisShaderMaterialignores scene
lighting entirely — it samples uTexCurrent / uTexNext directly — so there is nothing here for an environment map to feed. If a future revision of this component adds lighting, self-host the HDRI rather than depending on drei's third-party preset CDN.