# 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):
```js
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).
```html
<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;`
- **`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-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;`
```js
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)
```js
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
```js
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`)
```glsl
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.
```glsl
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)
```js
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:
```js
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):
```js
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:
```js
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:
```js
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`.
```js
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
```js
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);
  2. a red-toned close-up of a blonde woman screaming, lit hard against a dark red velvet curtain
     (dominant: saturated red, black);
  3. 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);
  4. 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.
