Hero Warp Slider
Goal
Build a full-screen WebGL hero slider: a Three.js fragment shader renders the current image cover-fit to the viewport, and clicking anywhere advances to the next slide through an expanding "lens-warp bubble" — a circular magnifying-lens distortion that grows from the center of the screen until the new image fills the frame. In sync, the overlay text (a big uppercase title split into characters, and a small description split into lines) slides out upward and the next slide's text slides back in, every fragment masked by an overflow: hidden wrapper. The star effect is the shader transition driven by a single GSAP tween on the uProgress uniform (0 → 1, 2.5s, power2.inOut) plus the GSAP/SplitText text choreography.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the SplitText plugin, and three (npm):
import * as THREE from "three";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
gsap.config({ nullTargetWarn: false });
Put the slide data in a sibling module slides.js (export const slides = [...]) and the two shader strings in shaders.js (export const vertexShader / fragmentShader), imported into script.js. No Lenis, no ScrollTrigger — the page doesn't scroll.
Layout / HTML
<div class="slider">
<canvas></canvas>
<div class="slider-content">
<div class="slide-title">
<h1>Quiet Green</h1>
</div>
<div class="slide-description">
<p>A cinematic study of solitude, nature, and a gaze that remembers something forgotten.</p>
<div class="slide-info">
<p>Type. Editorial</p>
<p>Field. Fine Art</p>
<p>Date. 2025</p>
</div>
</div>
</div>
</div>
<script type="module" src="./script.js"></script>
- The
<canvas>is the WebGL surface;.slider-contentis an absolutely-positioned DOM overlay on top of it. - Only slide 1 is in the initial HTML. Every later slide's content node is built in JS from the data module and swapped in during the transition.
slides.js — 4 entries, each { title, description, type, field, date, image }:
"Quiet Green"/ "A cinematic study of solitude, nature, and a gaze that remembers something forgotten." / Editorial / Fine Art / 2025 / image 1"Crimson Reign"/ "Ornate textures and ceremonial gold unravel across a sea of red—silent power in stillness." / Editorial / Conceptual / 2022 / image 2"Gilded Brow"/ "A baroque close-up capturing the tactile intimacy of skin, shadow, and the glitter of ritual." / Detail Study / Experimental / 2024 / image 3"Golden Flight"/ "A blur of motion in sun-soaked gold—freedom becomes visible only in the act of leaving." / Motion Still / Cinematic / 2023 / image 4
Styling
Font import: @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
Global reset * { margin:0; padding:0; box-sizing:border-box; }, body { font-family:"Inter"; }
h1:text-transform:uppercase; font-size:7vw; font-weight:700; line-height:1;p:font-size:0.95rem;.slider:position:relative; width:100vw; height:100svh; color:#fff; overflow:hidden;canvas:display:block; width:100%; height:100%;.slider-content:position:absolute; top:0; left:0; width:100%; height:100%; user-select:none; z-index:2;.slide-title:position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); width:100%; text-align:center;.slide-description:position:absolute; top:60%; left:60%; transform:translate(-50%,-50%); width:25%; display:flex; flex-direction:column; gap:2rem;(sits right-of-center, below the title).slide-info p:text-transform:uppercase;- Split-text scaffolding (required for the masked reveals):
.slide-title h1 { display:flex; justify-content:center; gap:0.2em; }.slide-title h1 .word { display:flex; }.slide-title h1 .char { display:block; }.char, .line { overflow:hidden; }— the masks.char span, .line span { display:inline-block; will-change:transform; position:relative; }— the animated inner spans- Responsive
@media (max-width:1000px):.slide-title { top:50%; }and.slide-description { width:75%; text-align:center; top:unset; bottom:5%; left:50%; transform:translate(-50%,-50%); }
Text splitting
Two different mechanisms:
Title — manual character splitter (function createCharacterElements(element); bail out if the element already contains .char nodes). Split textContent on spaces; for each word append <div class="word"> containing one <div class="char"><span>X</span></div> per character; between words append an extra .word holding <div class="char"><span> </span></div> (a real space character in a span). This gives per-character masks while the flex gap:0.2em on the h1 provides word spacing.
Description — SplitText lines. For every .slide-description p run new SplitText(element, { type: "lines", linesClass: "line" }), then post-process each .line by replacing its innerHTML with <span>${line.textContent}</span> so each line has an inner span to translate inside the overflow:hidden line.
Wrap both in processTextElements(container) — split the title h1 with the char splitter and all description ps (including the three .slide-info rows) with the line splitter.
Three.js scene (the stage)
scene = new THREE.Scene();camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);— a fullscreen quad rig.renderer = new THREE.WebGLRenderer({ canvas: document.querySelector("canvas"), antialias: true });thenrenderer.setSize(window.innerWidth, window.innerHeight);- One mesh:
new THREE.Mesh(new THREE.PlaneGeometry(2, 2), shaderMaterial)added to the scene. shaderMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader })with uniforms:uTexture1: { value: null }(current image),uTexture2: { value: null }(incoming image)uProgress: { value: 0.0 }— the transition driveruResolution: { value: new THREE.Vector2(innerWidth, innerHeight) }uTexture1Size/uTexture2Size:new THREE.Vector2(1, 1)— the pixel dimensions of each texture, needed for cover-fit.- Texture loading: with a
THREE.TextureLoader, load each slide's image sequentially (await a promise aroundloader.load); settexture.minFilter = texture.magFilter = THREE.LinearFilter;and stashtexture.userData = { size: new THREE.Vector2(image.width, image.height) };. Push into aslideTexturesarray. After loading, seeduTexture1with texture 0 anduTexture2with texture 1 (plus their sizes). - Plain
requestAnimationFrameloop callingrenderer.render(scene, camera)forever. - Resize handler:
renderer.setSize(innerWidth, innerHeight)anduResolution.value.set(innerWidth, innerHeight).
Shaders (shaders.js)
Vertex shader — passthrough forwarding uv as vUv:
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Fragment shader — the lens-warp bubble. Uniforms: sampler2D uTexture1, uTexture2; float uProgress; vec2 uResolution, uTexture1Size, uTexture2Size; varying vec2 vUv;
Helper 1 — CSS-cover UVs so any aspect ratio fills the screen without stretching:
vec2 getCoverUV(vec2 uv, vec2 textureSize) {
vec2 s = uResolution / textureSize;
float scale = max(s.x, s.y);
vec2 scaledSize = textureSize * scale;
vec2 offset = (uResolution - scaledSize) * 0.5;
return (uv * uResolution - offset) / scaledSize;
}
Helper 2 — push a UV along a direction (y displacement doubled for a stretched, anamorphic feel):
vec2 getDistortedUv(vec2 uv, vec2 direction, float factor) {
vec2 scaledDirection = direction;
scaledDirection.y *= 2.0;
return uv - scaledDirection * factor;
}
Helper 3 — the lens. Returns a struct LensDistortion { vec2 distortedUV; float inside; }:
LensDistortion getLensDistortion(vec2 p, vec2 uv, vec2 sphereCenter,
float sphereRadius, float focusFactor) {
vec2 distortionDirection = normalize(p - sphereCenter);
float focusRadius = sphereRadius * focusFactor;
float focusStrength = sphereRadius / 3000.0;
float focusSdf = length(sphereCenter - p) - focusRadius;
float sphereSdf = length(sphereCenter - p) - sphereRadius;
float inside = smoothstep(0.0, 1.0, -sphereSdf / (sphereRadius * 0.001));
float magnifierFactor = focusSdf / (sphereRadius - focusRadius);
float mFactor = clamp(magnifierFactor * inside, 0.0, 1.0);
mFactor = pow(mFactor, 5.0);
float distortionFactor = mFactor * focusStrength;
vec2 distortedUV = getDistortedUv(uv, distortionDirection, distortionFactor);
return LensDistortion(distortedUV, inside);
}
inside is a razor-thin smoothstep (width = 0.1% of the radius) — effectively a hard circular edge. Inside the bubble, distortion ramps from 0 at the inner "focus" circle (25% of the radius, sharp) to max at the rim, with pow(…, 5.0) biasing the warp hard toward the rim.
main() — work in pixel space:
void main() {
vec2 center = vec2(0.5, 0.5);
vec2 p = vUv * uResolution;
vec2 uv1 = getCoverUV(vUv, uTexture1Size);
vec2 uv2 = getCoverUV(vUv, uTexture2Size);
float maxRadius = length(uResolution) * 1.5;
float bubbleRadius = uProgress * maxRadius;
vec2 sphereCenter = center * uResolution;
float focusFactor = 0.25;
float dist = length(sphereCenter - p);
float mask = step(bubbleRadius, dist);
vec4 currentImg = texture2D(uTexture1, uv1);
LensDistortion distortion =
getLensDistortion(p, uv2, sphereCenter, bubbleRadius, focusFactor);
vec4 newImg = texture2D(uTexture2, distortion.distortedUV);
float finalMask = max(mask, 1.0 - distortion.inside);
vec4 color = mix(newImg, currentImg, finalMask);
gl_FragColor = color;
}
Net effect: at uProgress = 0 you see only texture 1; as progress rises a circle grows from screen center showing texture 2 warped like glass, and at uProgress = 1 (radius = 1.5 × the viewport diagonal) texture 2 covers everything, undistorted at the center.
GSAP effect (the important part — be exact)
State
currentSlideIndex = 0, isTransitioning = false. Global listeners:
window "load"→ run the intro text animation, then initialize the renderer/textures.document "click"(anywhere) →handleSlideChange().window "resize"→ resize handler above.
1) Intro on page load
After processTextElements on the initial .slider-content, grab chars = .char span and lines = .line span, then:
gsap.fromTo(chars, { y: "100%" }, { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out" })gsap.fromTo(lines, { y: "100%" }, { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out", delay: 0.2 })
Percentage y transforms, so each span rises exactly its own height out of the mask.
2) Click → shader transition
handleSlideChange():
- If
isTransitioning, return. SetisTransitioning = true. nextIndex = (currentSlideIndex + 1) % slides.length— infinite wrap.- Point the uniforms:
uTexture1= texture of the current slide,uTexture2= texture of the next, and copy bothuserData.sizevectors intouTexture1Size/uTexture2Size. - Kick off the text timeline (below) and the shader tween simultaneously:
gsap.fromTo(shaderMaterial.uniforms.uProgress,
{ value: 0 },
{
value: 1,
duration: 2.5,
ease: "power2.inOut",
onComplete: () => {
shaderMaterial.uniforms.uProgress.value = 0;
shaderMaterial.uniforms.uTexture1.value = slideTextures[nextIndex];
shaderMaterial.uniforms.uTexture1Size.value = slideTextures[nextIndex].userData.size;
},
}
);
The tween animates the uniform object's value property directly. On complete it snaps progress back to 0 and promotes the new texture to uTexture1 — visually seamless because at progress 1 the bubble already covers the screen.
3) Text out/in timeline (runs in parallel with the shader tween)
animateSlideTransition(nextIndex) builds one gsap.timeline():
- Chars out:
.to(currentContent.querySelectorAll(".char span"), { y: "-100%", duration: 0.6, stagger: 0.025, ease: "power2.inOut" })at time 0. - Lines out: same tween on
.line span(y: "-100%", 0.6s, stagger 0.025,power2.inOut) at position0.1— description trails the title by 100ms. .call(fn, null, 0.5)— at the 0.5s mark (while the outro tails are still finishing):- Build the next slide's
.slider-contentDOM node fromslides[nextIndex](same inner markup as the static HTML, withType./Field./Date.prefixes) with inlineopacity: 0. timeline.kill(), remove the old content node, append the new one to.slider.gsap.set(newContent.querySelectorAll("span"), { y: "100%" })as a pre-hide.setTimeout(…, 100)(lets layout settle so SplitText measures real line breaks), then:processTextElements(newContent);gsap.set([newChars, newLines], { y: "100%" });gsap.set(newContent, { opacity: 1 });- a new timeline with
onComplete: () => { isTransitioning = false; currentSlideIndex = nextIndex; }: - Chars in:
.to(newChars, { y: "0%", duration: 0.5, stagger: 0.025, ease: "power2.inOut" })at time 0. - Lines in:
.to(newLines, { y: "0%", duration: 0.5, stagger: 0.1, ease: "power2.inOut" }, 0.3)— lines enter with a chunkier 0.1 stagger, starting 0.3s in.
Timing summary: text exits over ~0.7s, new text enters from ~0.6s onward, all while the 2.5s bubble is still growing — the type settles well before the warp finishes. Clicks during the transition are ignored until the *text* timeline completes (isTransitioning is released by the text, not the shader tween).
Assets / images
4 full-bleed images, one per slide, loaded as WebGL textures and cover-fit by the shader — any aspect works, but a cohesive cinematic set around 3:2–16:9 landscape, ≥1600px wide looks best. Dark, painterly, editorial photography suits the white overlay type. Suggested roles:
- Slide 1 ("Quiet Green") — moody portrait of a fair-haired woman lit from the side against near-black, framed by dark green foliage with small orange berries and a white flower.
- Slide 2 ("Crimson Reign") — woman with long dark curls reclining on ornate red-and-gold brocade, gilded embroidery and gold objects around her.
- Slide 3 ("Gilded Brow") — extreme macro of skin and a brow crowned by a glittering gold jeweled headpiece in warm light.
- Slide 4 ("Golden Flight") — motion-blurred figure in pale cream running through sun-soaked golden grass with splashing water.
No brands or logos; any four images in this cinematic register work.
Behavior notes
- Click anywhere on the page advances (listener on
document), looping 1→2→3→4→1 forever. There are no arrows or dots. - Transitions are locked while one runs (
isTransitioningguard) — rapid clicks don't queue. - Textures are awaited sequentially before the first render, so the first click always has both textures ready.
- Responsive: below 1000px the title centers vertically and the description drops to a centered block near the bottom. The canvas resizes with the window (renderer size +
uResolution). - No scroll, no Lenis, no reduced-motion branch in the original.