Vanilla MotionVanilla Motion

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

MotionWebGLGLSLAnimation

Built with WebGL, GLSL, TypeScript

MobileDesktop
Open Demo

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.

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>

More

Ice Logo

Ice Logo

webglglslmotion
Ripple Reveal

Ripple Reveal

motioncsscss-maskanimation
Venetian Blind

Venetian Blind

motioncsscss-maskanimation