Ice LogoCursor01Web design · Transition
v2.0.0Ripple 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
- Section
- Web design
- Kind
- Transition
- Published
- Built with
- WebGL
- Version
- 2.0.0
- Tech
- WebGL, GLSL, TypeScript
npx vanilla-motion add ripple-reveal-webgl
Published Jul 22, 2026.
02How to use
Three ways- 01Customize it
Tune every value in the app until it looks right.
- 02Copy the embed
Settings are data-* attributes on the element, 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> - 03Paste it on your site
Anywhere that takes HTML: an Embed element in Webflow or Framer, a Custom HTML block in WordPress, or straight into your page.
- 01Connect once
Add it as an MCP server (or custom connector) in Claude, ChatGPT, Cursor, VS Code or any app that supports MCP.
https://mcp.vanilla-motion.com/mcp - 02Ask for it
In your project, ask:
Add Ripple Reveal WebGL to this project. - 03What it reads
The same files are open to any tool, with or without the MCP.
- 01shadcn
In a project that uses shadcn:
npx shadcn@latest add https://vanilla-motion.com/r/ripple-reveal-webgl.json - 02Any project
Copies the source into ./vanilla-motion/ripple-reveal-webgl/ and lists the packages it needs.
npx vanilla-motion add ripple-reveal-webgl - 03Use it
import { mount } from "./vanilla-motion/ripple-reveal-webgl/ripple-reveal-webgl"; const fx = mount(document.querySelector("#my-effect"), { /* params */ }); fx.setParams({ /* change any param later */ }); fx.dispose();
03Case study
3 min readWebGL 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.



