All components

Hero Warp Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen WebGL hero slider where clicking anywhere advances to the next image through an expanding lens-warp bubble rendered by a Three.js fragment shader. A gsap.fromTo tween drives the shader's uProgress uniform (2.5s, power2.inOut) to grow the distortion sphere, while a GSAP/SplitText timeline slides the per-character title and per-line description text out and back in.

How it's built

Categoryslider
Techthree, gsap
GSAP pluginsSplitText
Complexitysection
Performance costheavy
Mobile-safeyes

three.js webgl shader lens-distortion slider gsap splittext cinematic editorial

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

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-content is 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 }:

  1. "Quiet Green" / "A cinematic study of solitude, nature, and a gaze that remembers something forgotten." / Editorial / Fine Art / 2025 / image 1
  2. "Crimson Reign" / "Ornate textures and ceremonial gold unravel across a sea of red—silent power in stillness." / Editorial / Conceptual / 2022 / image 2
  3. "Gilded Brow" / "A baroque close-up capturing the tactile intimacy of skin, shadow, and the glitter of ritual." / Detail Study / Experimental / 2024 / image 3
  4. "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 }); then renderer.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 driver
  • uResolution: { 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 around loader.load); set texture.minFilter = texture.magFilter = THREE.LinearFilter; and stash texture.userData = { size: new THREE.Vector2(image.width, image.height) };. Push into a slideTextures array. After loading, seed uTexture1 with texture 0 and uTexture2 with texture 1 (plus their sizes).
  • Plain requestAnimationFrame loop calling renderer.render(scene, camera) forever.
  • Resize handler: renderer.setSize(innerWidth, innerHeight) and uResolution.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():

  1. If isTransitioning, return. Set isTransitioning = true.
  2. nextIndex = (currentSlideIndex + 1) % slides.length — infinite wrap.
  3. Point the uniforms: uTexture1 = texture of the current slide, uTexture2 = texture of the next, and copy both userData.size vectors into uTexture1Size/uTexture2Size.
  4. 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 position 0.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-content DOM node from slides[nextIndex] (same inner markup as the static HTML, with Type. / Field. / Date. prefixes) with inline opacity: 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:

  1. 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.
  2. Slide 2 ("Crimson Reign") — woman with long dark curls reclining on ornate red-and-gold brocade, gilded embroidery and gold objects around her.
  3. Slide 3 ("Gilded Brow") — extreme macro of skin and a brow crowned by a glittering gold jeweled headpiece in warm light.
  4. 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 (isTransitioning guard) — 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.