Skip to content

01Web design · Transition

v2.0.0

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

Section
Web design
Published

Published Jul 22, 2026.

02How to use

Three ways
  1. 01Customize it

    Tune every value in the app until it looks right.

  2. 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>
  3. 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.

03Case study

3 min read

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.

MobileDesktop
Open Demo

How it works

A fullscreen <canvas> element replaces all slide markup. One GLSL fragment shader does everything in a single pass:

  1. Compute aspect-correct distance from each pixel to the wave origin
  2. Apply Gaussian-enveloped sine ring distortion near the wave front
  3. Sample both slide textures at the displaced UV coordinates
  4. Mix prev/curr via a smoothstep radial 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.

04More effects

Web design