All components

Warp Lens Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen WebGL image slider where clicking anywhere expands a circular lens/bubble mask from the center, warping the incoming photo with a magnifying-glass distortion driven by a custom Three.js fragment shader. GSAP tweens the shader's uProgress uniform (2.5s power2.inOut) while SplitText staggers the title characters and description lines out and back in.

How it's built

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

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

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

Warp Lens Slider

Goal

Build a full-screen WebGL image slider: a Three.js canvas fills the viewport and shows one cinematic photograph at a time, with a big centered uppercase title and a small description block layered on top in HTML. Clicking anywhere advances to the next slide with the signature effect — a circular "lens bubble" expands from the exact center of the screen, revealing the incoming photo inside it while the bubble's rim warps the new image with a magnifying-glass distortion (a custom fragment shader). While the bubble grows, GSAP slides the title characters and description lines up out of their masks and staggers the next slide's text back in. Slides loop forever in one direction.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the SplitText plugin, plus three (npm) for the WebGL layer:

import * as THREE from "three";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";

Call gsap.registerPlugin(SplitText) and gsap.config({ nullTargetWarn: false }) (content nodes are removed mid-timeline, so null targets must not warn). You may keep the slide data and the GLSL shader strings in separate local modules (e.g. slides.js, shaders.js) or inline in script.js.

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>

Only the first slide lives in the static HTML. Subsequent .slider-content blocks are built in JS from a data array and swapped in during transitions.

Slide data (4 slides)
const slides = [
  { title: "Quiet Green",   description: "A cinematic study of solitude, nature, and a gaze that remembers something forgotten.",          type: "Editorial",    field: "Fine Art",     date: "2025", image: "./img-1.jpg" },
  { title: "Crimson Reign", description: "Ornate textures and ceremonial gold unravel across a sea of red—silent power in stillness.",     type: "Editorial",    field: "Conceptual",   date: "2022", image: "./img-2.jpg" },
  { title: "Gilded Brow",   description: "A baroque close-up capturing the tactile intimacy of skin, shadow, and the glitter of ritual.",  type: "Detail Study", field: "Experimental", date: "2024", image: "./img-3.jpg" },
  { title: "Golden Flight", description: "A blur of motion in sun-soaked gold—freedom becomes visible only in the act of leaving.",        type: "Motion Still", field: "Cinematic",    date: "2023", image: "./img-4.jpg" },
];

The dynamically-built content uses the exact same markup as the static block, with the info lines templated as Type. ${type}, Field. ${field}, Date. ${date}.

Styling

Font: Inter from Google Fonts (variable weights). 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; (sits above the canvas; text is white over the photo)
  • .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; (offset right of center, below the title)
  • .slide-info p: text-transform: uppercase;

Text-mask plumbing (essential for the reveal effect):

  • .slide-title h1: display: flex; justify-content: center; gap: 0.2em; (the gap renders the space between words)
  • .slide-title h1 .word: display: flex;
  • .slide-title h1 .char: display: block;
  • .char, .line: overflow: hidden; — these are the clipping masks
  • .char span, .line span: display: inline-block; will-change: transform; position: relative; — these are what GSAP moves

Responsive (@media (max-width: 1000px)): .slide-title { top: 50%; }; .slide-description { width: 75%; text-align: center; top: unset; bottom: 5%; left: 50%; transform: translate(-50%, -50%); }.

GSAP effect (be exhaustive)

Text splitting

Two different mechanisms:

  • Title (h1) — manual character split (not SplitText): split textContent on spaces; for each word create <div class="word">, and inside it one <div class="char"><span>X</span></div> per character. Between words append an extra .word containing <div class="char"><span> </span></div> (a literal space span). Guard: if the element already contains .char nodes, skip (never double-split).
  • Description paragraphs — GSAP SplitText: new SplitText(p, { type: "lines", linesClass: "line" }), then replace each .line's innerHTML with <span>${line.textContent}</span> so every line has an inner span to animate inside the overflow:hidden line mask.

A processTextElements(container) helper applies the char split to .slide-title h1 and the line split to every .slide-description p (both paragraphs: the description and each .slide-info p — select all p inside .slide-description).

Intro animation (on load)

After splitting the initial content:

  • gsap.fromTo(charSpans, { y: "100%" }, { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out" })
  • gsap.fromTo(lineSpans, { y: "100%" }, { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out", delay: 0.2 })
Three.js setup
  • THREE.Scene() + THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1).
  • THREE.WebGLRenderer({ canvas, antialias: true }), renderer.setSize(window.innerWidth, window.innerHeight).
  • One full-screen quad: new THREE.Mesh(new THREE.PlaneGeometry(2, 2), shaderMaterial).
  • THREE.ShaderMaterial with uniforms:
  • uTexture1: null (outgoing image), uTexture2: null (incoming image)
  • uProgress: 0.0 (the transition driver, 0→1)
  • uResolution: new THREE.Vector2(window.innerWidth, window.innerHeight)
  • uTexture1Size / uTexture2Size: new THREE.Vector2(1, 1) (natural pixel sizes, needed for cover-fit)
  • Load all 4 slide images sequentially with THREE.TextureLoader (await each). For each texture set minFilter = magFilter = THREE.LinearFilter and stash new THREE.Vector2(image.width, image.height) (e.g. in texture.userData.size). After loading, assign texture 0 → uTexture1, texture 1 → uTexture2, plus their sizes.
  • Plain requestAnimationFrame loop calling renderer.render(scene, camera) every frame.
Fragment shader (the warp — reproduce this math exactly)

Vertex shader is a pass-through that forwards uv as vUv. The fragment shader composites the two textures with an expanding lens bubble:

// cover-fit UV (like CSS object-fit: cover), per texture:
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;
}

// radial push, with the vertical component doubled:
vec2 getDistortedUv(vec2 uv, vec2 direction, float factor) {
  vec2 scaledDirection = direction;
  scaledDirection.y *= 2.0;
  return uv - scaledDirection * factor;
}

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;          // inner "clear" zone
  float focusStrength = sphereRadius / 3000.0;             // warp amplitude grows with the bubble
  float focusSdf = length(sphereCenter - p) - focusRadius;
  float sphereSdf = length(sphereCenter - p) - sphereRadius;
  float inside = smoothstep(0.0, 1.0, -sphereSdf / (sphereRadius * 0.001)); // ~binary inside-bubble mask, hairline AA edge

  float magnifierFactor = focusSdf / (sphereRadius - focusRadius); // 0 at focus ring -> 1 at rim
  float mFactor = clamp(magnifierFactor * inside, 0.0, 1.0);
  mFactor = pow(mFactor, 5.0);                             // warp concentrated at the rim

  float distortionFactor = mFactor * focusStrength;
  vec2 distortedUV = getDistortedUv(uv, distortionDirection, distortionFactor);
  return LensDistortion(distortedUV, inside);
}

void main() {
  vec2 center = vec2(0.5, 0.5);
  vec2 p = vUv * uResolution;                  // pixel space
  vec2 uv1 = getCoverUV(vUv, uTexture1Size);
  vec2 uv2 = getCoverUV(vUv, uTexture2Size);

  float maxRadius = length(uResolution) * 1.5; // bubble fully covers the screen at progress 1
  float bubbleRadius = uProgress * maxRadius;
  vec2 sphereCenter = center * uResolution;    // dead center of the viewport
  float focusFactor = 0.25;

  float dist = length(sphereCenter - p);
  float mask = step(bubbleRadius, dist);       // 1 outside the bubble, 0 inside

  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);
  gl_FragColor = mix(newImg, currentImg, finalMask); // inside bubble: warped new image; outside: current image
}

Reading of the effect: at uProgress = 0 the bubble has zero radius so only the current image shows. As uProgress rises, a hard-edged circle grows from the center; inside it you see the incoming image, sampled through a radial magnifier whose strength peaks at the circle's rim (pow(…, 5.0)) and whose vertical displacement is 2× the horizontal — the new photo looks smeared/stretched at the bubble edge and clean near the center. At uProgress = 1 the radius (1.5 × diagonal) exceeds the screen, the warp has relaxed, and the new image fully covers the frame.

Click transition

State: currentSlideIndex = 0, isTransitioning = false. On click anywhere on .slider:

  1. If isTransitioning, ignore. Set isTransitioning = true; nextIndex = (currentSlideIndex + 1) % slides.length.
  2. Point the uniforms: uTexture1 = texture of the current slide, uTexture2 = texture of the next slide (plus both u…Size uniforms).
  3. Shader tween (runs in parallel with the text): gsap.fromTo(shaderMaterial.uniforms.uProgress, { value: 0 }, { value: 1, duration: 2.5, ease: "power2.inOut", onComplete }). In onComplete: reset uProgress.value = 0 and set uTexture1 (and uTexture1Size) to the next slide's texture, so the resting frame shows the new image with the bubble collapsed.
  4. Text-out / text-in timeline (gsap.timeline()):
  5. .to(currentChar spans, { y: "-100%", duration: 0.6, stagger: 0.025, ease: "power2.inOut" }) — title exits upward through its masks.
  6. .to(currentLine spans, { y: "-100%", duration: 0.6, stagger: 0.025, ease: "power2.inOut" }, 0.1) — description lines follow, starting at absolute position 0.1.
  7. .call(fn, null, 0.5) — at the 0.5 s mark: kill this timeline, remove() the old .slider-content, build the next slide's content node (created with inline opacity: 0), append it to .slider, and gsap.set all its spans to y: "100%". Then, after a setTimeout(…, 100) (lets the DOM settle so SplitText measures real line breaks):
  8. run processTextElements(newContent) (char + line splitting),
  9. gsap.set the fresh .char span and .line span collections to y: "100%", gsap.set(newContent, { opacity: 1 }),
  10. play the entrance timeline with onComplete: () => { isTransitioning = false; currentSlideIndex = nextIndex; }:
  11. .to(newChars, { y: "0%", duration: 0.5, stagger: 0.025, ease: "power2.inOut" })
  12. .to(newLines, { y: "0%", duration: 0.5, stagger: 0.1, ease: "power2.inOut" }, 0.3)

Net choreography: the old text is fully gone by ~0.5 s, the new text lands by ~1.4 s, while the lens bubble keeps expanding until 2.5 s — the text swap happens inside the slow warp, which is what makes it feel cinematic.

Resize

On window resize: renderer.setSize(innerWidth, innerHeight) and update uResolution — the cover-fit UVs adapt automatically.

Assets / images

4 full-bleed photographic slide textures (one per slide), loaded as WebGL textures and cover-fitted by the shader, so any reasonably large landscape image works (~16:9 or bigger; 1920 px+ wide recommended). They should read as one cohesive dark, cinematic, fine-art editorial series so the white text stays legible:

  1. *Quiet Green* — moody chiaroscuro portrait wrapped in dark green foliage on a near-black background.
  2. *Crimson Reign* — opulent figure in red-and-gold brocade on a matching crimson textured set with scattered gold.
  3. *Gilded Brow* — extreme warm-lit baroque close-up of skin and lashes beneath a glittering gold headpiece.
  4. *Golden Flight* — motion-blurred figure in a pale dress running through sunlit golden grass.

Behavior notes

  • Click only — no scroll hijacking, no keyboard, no autoplay. Every click advances one slide, always forward, looping 0→1→2→3→0….
  • isTransitioning locks input for the full text choreography (released when the entrance timeline completes); clicks during a transition are ignored.
  • Textures are all loaded up-front (sequential awaits) before the render loop starts; the first painted frame is slide 1.
  • The .char/.line masks plus inner spans are the entire reveal mechanism — without overflow: hidden on the wrappers the y-percent slides won't clip.
  • gsap.config({ nullTargetWarn: false }) is required because the outgoing content node is removed while its timeline is still referenced.
  • Below 1000 px the description recenters at the bottom; the WebGL effect itself is resolution-independent. Heavy on GPU — desktop-first. No reduced-motion handling in the original.