# Ripple Reveal WebGL - Skill Reference > A WebGL slideshow transition: one fragment shader reveals the next slide through an expanding radial mask while a Gaussian-enveloped sine ring refracts the pixels at the wave front, with an optional pointer "water" layer on top. ## What this teaches - A single-pass GLSL fragment shader that samples two slide textures (`uPrev`, `uCurr`) and blends them with a `smoothstep` radial mask - Gaussian leading edge plus exponential trailing wake as the envelope of a `sin()` ring, to displace UVs radially (true pixel refraction, not an overlay) - Ken Burns zoom done in UV space (`zoomUv`) with independent clocks for the outgoing and incoming slide - Aspect-correct distance (`uv.x * aspect`) so the wave is a circle on any container size - `setInterval` for slide cadence plus a `requestAnimationFrame` loop for drawing, with `setParams` read every frame - A height-field water simulation (WebGL2, ping-pong half-float framebuffers) that refracts the slideshow canvas beneath it ## Core concept The slideshow never moves DOM nodes: it is one canvas and one program. Progress `uT` (0 to 1, eased) drives two things from the same origin: a mask circle that grows to cover the farthest corner, and a thinner ring just behind and ahead of the same front that offsets the sample UVs. Because the displacement is applied to the UV before both textures are sampled, the mask edge and the refraction stay locked together. The water layer is a separate canvas that re-captures the slideshow canvas each frame, which is why the slideshow context is created with `preserveDrawingBuffer: true`. ## Use it ### Embed (no build step) ```html
``` - Images are read from the element's `` children (`currentSrc || src`) by `optionsFromElement`. There is no `data-images` attribute. - Every key of `defaults` is a kebab-case `data-*` attribute: `originX` becomes `data-origin-x`, `kenBurnsDuration` becomes `data-ken-burns-duration`, `waterClick` becomes `data-water-click`. - Numbers go through `Number(...)`. Booleans: `"false"` and `"0"` are off, anything else is on (`data-water` with no value is on). - Images on another origin need CORS headers (`crossOrigin = 'anonymous'` is set before loading). - The script exposes `window.RippleRevealWebGL` with `init(root)` (start any not-yet-started `[data-ripple-reveal-webgl]` under `root`, for DOM you add later), `get(el)` (the running handle: `setParams`, `dispose`), plus `mount`, `defaults`, `RippleRevealWebGL`, `WaterRipples`, `optionsFromElement`. ```js // elements added after load RippleRevealWebGL.init(document.querySelector('#new-section')); // change a running instance RippleRevealWebGL.get(el).setParams({ duration: 1.5, water: false }); ``` ### Bundled apps Files come from `npx vanilla-motion add ripple-reveal-webgl` (into `./vanilla-motion/ripple-reveal-webgl/`). ```ts import { mount } from "./vanilla-motion/ripple-reveal-webgl/ripple-reveal-webgl"; // images: option wins; if omitted, the container's children are used const fx = mount(container, { images: ["/a.jpg", "/b.jpg", "/c.jpg"], // at least two, otherwise it warns and does nothing originX: 0, originY: 0, delay: 4, }); fx.setParams({ ringSpeed: 1.5, water: false }); // live; any subset of the params below fx.dispose(); // stops the loops, removes the canvas(es), releases the WebGL context ``` The handle is `RippleRevealWebGLHandle { setParams(next: Partial): void; dispose(): void }`. Options are `Partial & { images?: string[] }`. `mount` clears the container's children and sets `overflow: hidden` (and `position: relative` if it was static), so give the container a size. ### Params Defaults from `params.ts`; ranges are the panel's slider limits (the code accepts any number). | Key | Default | Range | Meaning | | --- | --- | --- | --- | | `originX` | `0.5` | 0 to 1 | Wave origin across the width (0 left, 1 right) | | `originY` | `0.5` | 0 to 1 | Wave origin down the height (0 top, 1 bottom) | | `feather` | `0.15` | 0 to 0.3 | Soft edge of the reveal mask (0 hard) | | `duration` | `1` | 0.2 to 4 | Seconds one reveal takes | | `delay` | `3` | 0.5 to 12 | Seconds a slide stays before the next reveal | | `kenBurnsDuration` | `10` | 1 to 30 | Seconds of the slow 1 to 1.1 zoom per slide | | `distortScale` | `40` | 0 to 100 | Ring displacement strength (0 off) | | `ringCount` | `8` | 1 to 20 | Number of sine rings | | `ringWidth` | `0.05` | 0.01 to 0.3 | Gaussian band width around the front (smaller is narrower) | | `ringLength` | `2` | 0.2 to 5 | Spatial wavelength (larger shows fewer rings) | | `ringSpeed` | `1` | 0.2 to 3 | Ring travel speed multiplier | | `decay` | `3` | 0 to 20 | Trailing wake length (0 is a clean leading edge) | | `water` | `true` | on/off | Pointer ripple layer (mouse devices only) | | `waterClick` | `true` | on/off | Stronger drop on click as well as the move trail | | `waterResolution` | `256` | no slider; 128 soft, 256 balanced, 512 crisp | Simulation width in texels; read only when the water layer is (re)built | | `dropRadius` | `20` | 4 to 80 | Radius of each drop in CSS px | | `perturbance` | `0.015` | 0 to 0.08 | Refraction strength of the water | | `persistence` | `0.65` | 0 to 1 | How long ripples keep ringing | ## Key code ### Canvas and textures ```ts // the container becomes the stage: children cleared, one absolutely positioned canvas container.innerHTML = '' container.style.overflow = 'hidden' // preserveDrawingBuffer so the water layer can read this canvas every frame this.gl = canvas.getContext('webgl', { preserveDrawingBuffer: true })! // 1x1 placeholder texture per slide so drawing can start before images arrive gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([40, 40, 40, 255])) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) // clamp so displaced UVs never wrap // upload: flip Y so UV (0,0) matches the CSS top-left convention img.crossOrigin = 'anonymous' gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img) // canvas size = CSS size x min(devicePixelRatio, 2), kept in sync by a ResizeObserver ``` A full-screen quad (two triangles, `position` in clip space) is the only geometry. `start()` awaits all image loads (a failed image resolves anyway and stays the grey placeholder), then starts the draw loop and the interval. ### Fragment shader ```glsl vec2 origin = vec2(uOrigin.x, 1.0 - uOrigin.y); // CSS Y=0 is top, GL Y=0 is bottom float aspect = uResolution.x / uResolution.y; float dist = distance(vec2(uv.x * aspect, uv.y), vec2(origin.x * aspect, origin.y)); // round on any size float radius = uT * uRingSpeed * 2.0; // ring front travels outward float x = dist - radius; // signed distance to the front // ahead of the front: tight Gaussian; behind it: exponential wake scaled by uDecay float band = x > 0.0 ? exp(-pow(x / uRingWidth, 2.0)) : exp(x / max(uDecay * uRingWidth, 0.0001)); float rings = sin(x * uRingCount * 6.28318 / max(uRingLength, 0.001)); float fade = clamp(1.0 - radius / 2.0, 0.0, 1.0); // die out as the ring leaves the view vec2 dispUv = uv + normalize(uv - origin + 0.0001) * rings * band * uAmplitude * fade; // push radially // Ken Burns in UV space; both samples use the displaced UV so mask and refraction agree vec4 prevColor = texture2D(uPrev, clamp(zoomUv(dispUv, uKenBurnsPrev, origin), 0.0, 1.0)); vec4 currColor = texture2D(uCurr, clamp(zoomUv(dispUv, uKenBurnsCurr, origin), 0.0, 1.0)); // radial mask: grows to the farthest corner; uFeather widens the smoothstep band float maxDist = sqrt(aspect * aspect + 1.0) * 0.75; float maskRadius = uT * maxDist; float halfFeather = uFeather * maxDist * 0.5; gl_FragColor = mix(prevColor, currColor, smoothstep(maskRadius + halfFeather, maskRadius - halfFeather, dist)); ``` `uAmplitude` is `distortScale / 1000`. Uniforms are re-uploaded every frame from `this.p`, so `setParams` needs no restart. When idle, `uT = 1`, `uAmplitude = 0` and both textures are the current slide, so only the Ken Burns zoom shows. ### Timing and slide state ```ts // the interval only flips state; drawing reads it on the next frame private advance() { this.kbStartPrev = this.kbStart // outgoing keeps its running zoom clock this.current = (this.current + 1) % this.srcs.length this.animStart = this.kbStart = performance.now() // incoming starts a fresh zoom this.isAnimating = true } // in draw(): clamp to 0 first, a slightly-early frame would make pow(negative, 1.8) NaN and flash garbage const rawT = Math.min(Math.max((now - this.animStart) / animDuration, 0), 1) t = Math.pow(rawT, 1.8) // ease-in, approximates cubic-bezier(0.4, 0, 1, 1) if (rawT >= 1) this.isAnimating = false // Ken Burns: 0 to 0.1 zoom, capped, one clock per slide const kenCurr = Math.min((now - this.kbStart) / kbDuration, 1) * 0.1 ``` `setParams` re-arms the interval only when `delay` changed, and rebuilds the water layer only when one of the water keys changed (`water`, `waterClick`, `waterResolution`, `dropRadius`, `perturbance`, `persistence`). ### Water ripples `WaterRipples` (exported too) is a WebGL2 port of the `jquery.ripples` simulation core. It overlays a `pointer-events: none` canvas on the container and refracts what is beneath. ```glsl // update pass: r = height, g = velocity, on two ping-pong RGBA16F targets float average = (n(-dx) + n(-dy) + n(+dx) + n(+dy)) * 0.25; // neighbours' height info.g += (average - info.r) * 2.0; // accelerate toward the neighbour average info.g *= uDamping; // lose energy each frame info.r += info.g; // render pass: normal from the height gradient bends the background sample vec2 offset = -normalize(cross(dy, dx)).xz; gl_FragColor = texture2D(uBackground, clamp(vUv + offset * uPerturbance, 0.0, 1.0) * uBgScale + uBgOffset) + specular; ``` ```ts // persistence 0-1 -> per-frame damping; exponential because it compounds ~60x/s const damping = 1 - 0.1 * Math.pow(0.02, persistence) // 0 -> 0.9, 0.65 -> ~0.992, 1 -> 0.998 // pointermove drops: radius dropRadius, strength 0.005; pointerdown: radius x1.5, strength 0.06 ``` - The background source is, in order, a `` in the container (recaptured every frame, this is the slideshow canvas), else an ``, else a CSS background image (uploaded once, cover-fitted). - It mounts nothing without `(hover: hover) and (pointer: fine)`, with `prefers-reduced-motion: reduce`, without WebGL2, or when float render targets are missing. The slideshow itself still runs. - Simulation height follows the container aspect (`simW * h / w`) so waves travel isotropically. - `drop(xPx, yPx, radiusPx, strength)` injects a drop programmatically; `interactive: false` disables pointer listeners. `RippleRevealWebGL` always uses interactive mode. ## Variants ### Default (default) Wave starts at the centre (`originX/Y: 0.5`), slow cycle (`delay: 6`), water layer on with a short trail (`waterClick: false`, `persistence: 0.15`). Four picsum images; needs a mouse to see the water. ### Top-left origin (top-left) `originX: 0, originY: 0` moves the front to the top-left corner: the same shader handles any origin since `dist` is measured from `uOrigin` and the mask radius covers the farthest corner. `duration: 1`, `delay: 3`, `water: false` (the demo shows the transition only). ## Adapt to your layout - **Origin**: any `originX`/`originY` in 0 to 1; set it from a click position in your own code and call `setParams` before the next reveal. - **Strength of the ring**: `distortScale: 0` gives a plain radial crossfade; raise `ringCount` or lower `ringLength` for tighter rings. - **Clean front**: `decay: 0` removes the trailing wake; `ringWidth` controls how thick the leading band is. - **Cadence**: `duration` is the reveal, `delay` is the whole pause between reveal starts (a `delay` shorter than `duration` restarts mid-reveal). - **Water cost**: `waterResolution: 128` for low-end GPUs, `512` for crisp ripples; set `water: false` to skip the second context. - **Sizing**: the stage is the container, so set its width and height (or `aspect-ratio`); the canvas follows via `ResizeObserver`. - **Slides**: at least two; images should be roughly the container's aspect, as the shader samples them stretched to the canvas (no cover-fit for slides). ## Browser support WebGL 1 for the slideshow (all modern browsers). The water layer needs WebGL2 with `EXT_color_buffer_float` or `EXT_color_buffer_half_float` and a fine hover pointer, and is skipped silently otherwise. If shader compile or link fails, the constructor throws. Cross-origin slide images need CORS headers. ## Accessibility ```ts // reduced motion: the water layer skips itself, but the slideshow keeps moving unless you skip mount if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { mount(container, { images }) } ``` Skip the slideshow entirely for reduced motion and show the first image instead. Shortening the duration is not enough: the radial wave and the Ken Burns zoom are both large motion. The canvas has no text alternative, so put the meaning in surrounding text or an `aria-label` on the container.