Ripple Reveal WebGL
Slideshow transition using WebGL with radial mask crossfade and Gaussian sine ring distortion for true pixel-level wave refraction at the reveal edge
Built with WebGL, GLSL, TypeScript
WebGL canvas replaces CSS mask entirely. A fragment shader crossfades two slide textures behind a radial mask while Gaussian-enveloped sine rings physically distort pixels at the wave front — true refractive warping, not a visual overlay.
Ripple reveal WebGL
Circle expands from a configurable origin, distorting image pixels at the wave edge like light refracting through moving water.
Top-left origin
Set originX: 0, originY: 0 (or data-origin-x="0" data-origin-y="0") — the same shader handles any origin.
How it works
A fullscreen <canvas> element replaces all slide markup. One GLSL fragment shader does everything in a single pass:
- Compute aspect-correct distance from each pixel to the wave origin
- Apply Gaussian-enveloped sine ring distortion near the wave front
- Sample both slide textures at the displaced UV coordinates
- Mix prev/curr via a
smoothstepradial mask
Ring distortion
float radius = uT * uRingSpeed * 2.0;
float x = dist - radius; // signed dist from wave front
// leading edge: tight Gaussian bell; trailing wake: exponential decay (decay)
float band = x > 0.0
? exp(-pow(x / uRingWidth, 2.0))
: exp(x / max(uDecay * uRingWidth, 0.0001));
float freq = uRingCount * 6.28318 / max(uRingLength, 0.001);
float rings = sin(x * freq); // oscillation in the band
float disp = rings * band * uAmplitude * fade;
vec2 dir = normalize(uv - origin + 0.0001);
vec2 dispUv = uv + dir * disp; // push pixels radially
x = dist - radius is negative ahead of the wave, zero at the front, positive behind. The Gaussian bell (band) ensures only pixels close to the wave front get displaced — the effect stays confined to the ring, not the whole image.
The trailing wake (x < 0) uses a different envelope: exponential decay scaled by decay. At decay: 0 the exponential collapses to zero instantly, giving a clean leading edge only. At decay: 3 (default) the distortion fades out slowly behind the wave like a comet tail.
Radial mask crossfade
float maxDist = sqrt(aspect * aspect + 1.0) * 0.75;
float maskRadius = uT * maxDist;
float halfFeather = uFeather * maxDist * 0.5;
float maskAlpha = smoothstep(maskRadius + halfFeather, maskRadius - halfFeather, dist);
gl_FragColor = mix(prevColor, currColor, maskAlpha);
smoothstep produces the soft feather zone. maxDist * 0.75 covers the farthest corner from any origin position.
Params → WebGL uniforms
// params.ts: one definition, also the data-* attributes (data-ring-speed) and the panel
const { originX, originY, feather, ringCount, ringWidth, ringLength, ringSpeed, decay } = this.p
const amplitude = this.p.distortScale / 1000
Every value lives in params.ts and reaches the shader as a uniform. The values are read every frame, so setParams({ ringSpeed: 2 }) takes effect without a restart.
Texture management
All slide images preload into WebGL textures before the first frame:
await Promise.all(this.srcs.map((src, i) => this.loadImage(this.textures[i], src)))
gl.UNPACK_FLIP_Y_WEBGL flips Y on upload so UV (0,0) maps to the image top-left (matching CSS convention).
Ken Burns zoom
UV scaling in the shader — no CSS transform needed:
vec2 zoomUv(vec2 uv, float zoom, vec2 origin) {
return (uv - origin) / (1.0 + zoom) + origin;
}
uKenBurnsCurr goes 0→0.1 during the transition; uKenBurnsPrev stays fixed at 0.1 (outgoing slide is already fully zoomed). Both run from their own independent clocks so the outgoing slide doesn’t stutter when a new transition begins.
Accessibility
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
mount(el, { images })
}
Skip mount() entirely — don’t just reduce duration. The Ken Burns zoom alone can trigger vestibular symptoms at full speed.
Use it on a site
One script, no build step. Every element with data-ripple-reveal-webgl starts the effect; settings go in data-* attributes in kebab-case.
<div data-ripple-reveal-webgl style="height:480px"></div>
<script src="https://vanilla-motion.com/embed/ripple-reveal-webgl/2.0.0/ripple-reveal-webgl.js"></script>

