# Ripple Reveal WebGL - Skill Reference
> Slideshow transition using a fullscreen WebGL canvas. Radial mask crossfades slides while Gaussian-enveloped sine rings distort image pixels at the wave front — true refractive distortion, not a CSS overlay.
## What this teaches
- WebGL fullscreen quad setup (vertex buffer, attribute pointer, `drawArrays`)
- Two-texture crossfade in GLSL: `mix(prev, curr, maskAlpha)`
- Aspect-correct UV distance for round circles on any viewport
- Gaussian-enveloped sine ring: `exp(-pow(x/width, 2)) * sin(x * freq)` for physical wave distortion
- `smoothstep` radial mask replacing CSS `@property` + `mask: radial-gradient`
- `gl.UNPACK_FLIP_Y_WEBGL` to align WebGL UV origin with image top-left
- Ken Burns zoom via UV scaling in shader (`zoomUv` helper)
- CSS custom properties as config API for WebGL uniforms — `getComputedStyle` → `gl.uniform*`
- `ResizeObserver` + DPR-aware canvas resize
- `Promise.all` texture preloading before first frame
## Core concept
WebGL owns the entire output. One fragment shader handles both the reveal and the distortion in a single pass:
1. **Distance field**: aspect-corrected `distance(aUv, aOr)` gives a round circle on any viewport
2. **Wave front**: `radius = uT * uRingSpeed * 2.0` expands outward as `uT` goes `0→1`
3. **Ring distortion**: signed distance `x = dist - radius` feeds a Gaussian bell × sine — pixels near the wave front get displaced radially
4. **Reveal mask**: `smoothstep` on `dist` vs `maskRadius` crossfades prev→curr texture
5. **Ken Burns**: `zoomUv` scales UV around origin so slides slowly zoom during display
## Key code
### HTML structure
```html
```
JS replaces `.slideshow-ripple-reveal-webgl` innerHTML with a single ``.
### CSS — config vars only
```css
.slideshow-ripple-reveal-webgl {
--origin-x: 50%; /* wave origin horizontal */
--origin-y: 50%; /* wave origin vertical */
--feather: 0.15; /* mask soft edge (0=hard, 0.3=soft) */
--duration: 1s; /* reveal animation duration */
--delay: 3s; /* pause between slides */
--distort-scale: 40; /* displacement strength (0=off, 80=strong) */
--ring-count: 8; /* number of sine rings */
--ring-width: 0.05; /* Gaussian falloff tightness */
--ring-length: 2; /* spatial wavelength */
--ring-speed: 1; /* ring expansion speed multiplier */
--decay: 3; /* trailing wake behind wave front (0=none, 20=long tail) */
}
```
No mask, no animation, no `@property`. All effect is in GLSL.
### GLSL — ring distortion
```glsl
float radius = uT * uRingSpeed * 2.0;
float x = dist - radius; // signed dist from wave front
// leading edge: tight Gaussian; trailing wake: exponential decay controlled by --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); // sine oscillation
float fade = clamp(1.0 - radius / 2.0, 0.0, 1.0); // fade as ring exits viewport
float disp = rings * band * uAmplitude * fade;
vec2 dir = normalize(uv - origin + 0.0001);
vec2 dispUv = uv + dir * disp; // displace radially from origin
```
### GLSL — reveal mask
```glsl
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);
```
### JS — CSS var → uniform pipeline
```ts
private readConfig() {
const cs = getComputedStyle(this.container)
return {
originX: parseCssPct(cs.getPropertyValue('--origin-x'), 0.5),
feather: parseCssNum(cs.getPropertyValue('--feather'), 0.15),
amplitude: parseCssNum(cs.getPropertyValue('--distort-scale'), 20) / 1000,
ringSpeed: parseCssNum(cs.getPropertyValue('--ring-speed'), 1),
// ...etc
}
}
```
## Variants
### Default (index.html)
Center origin, 4 slides, 3s delay, 1s reveal.
### Top-left (index-top-left.html)
`--origin-x: 0%; --origin-y: 0%` — wave expands from top-left corner.
## Adapt to your layout
- **Origin**: set `--origin-x` / `--origin-y` on container or inline override
- **Distortion strength**: `--distort-scale` (0 = no distortion, 80 = heavy warp)
- **Ring shape**: `--ring-count` (more = tighter rings), `--ring-width` (narrower = sharper band), `--ring-length` (spatial spacing)
- **Ring speed**: `--ring-speed` — controls how fast distortion ring expands relative to animation progress
- **Trailing wake**: `--decay` — exponential tail behind the wave front; 0 = no wake, 5 = medium, 20 = long comet tail
- **Reveal speed**: `--duration` — shorter = faster crossfade
- **Feather**: `--feather` — 0 = hard circle wipe, 0.3 = wide soft dissolve
## Browser support
WebGL 1.0 (all modern browsers, IE11 with caveats) · No `@property` dependency · No CSS mask dependency · Falls back to black canvas if WebGL unavailable — add a check and `display: none` the canvas in that case.
## Accessibility
```ts
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
new RippleSlideshow(el).start()
} else {
// show first slide statically — canvas still renders, just no advance() call
}
```
## Full source
### HTML (critical structure)
```html
```
### script.ts (full)
```ts
// ── Shaders ───────────────────────────────────────────────────────────────────
const VS = /* glsl */`
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FS = /* glsl */`
precision mediump float;
uniform sampler2D uPrev; // outgoing slide texture
uniform sampler2D uCurr; // incoming slide texture
uniform vec2 uOrigin; // wave origin 0-1 (CSS convention: Y=0 top)
uniform vec2 uResolution; // canvas physical pixels
uniform float uT; // 0→1 progress over animation duration
uniform float uFeather; // mask soft edge (fraction of reveal radius)
uniform float uAmplitude; // distortion strength in UV space
uniform float uRingCount; // number of sine rings
uniform float uRingWidth; // Gaussian falloff width
uniform float uRingLength; // spatial wavelength (larger = fewer visible rings)
uniform float uRingSpeed; // ring expansion speed multiplier
uniform float uDecay; // trailing wake length (0=none, higher=longer tail)
uniform float uKenBurnsCurr; // 0→0.1 zoom progress for incoming slide
uniform float uKenBurnsPrev; // fixed 0.1 (outgoing already zoomed)
varying vec2 vUv;
vec2 zoomUv(vec2 uv, float zoom, vec2 origin) {
return (uv - origin) / (1.0 + zoom) + origin;
}
void main() {
vec2 uv = vUv;
// aspect-correct distance so circle looks round on any viewport
float aspect = uResolution.x / uResolution.y;
// flip origin Y: CSS convention Y=0 is top, WebGL UV Y=0 is bottom
vec2 origin = vec2(uOrigin.x, 1.0 - uOrigin.y);
vec2 aUv = vec2(uv.x * aspect, uv.y);
vec2 aOr = vec2(origin.x * aspect, origin.y);
float dist = distance(aUv, aOr);
// wave front expands outward over time
float radius = uT * uRingSpeed * 2.0;
float x = dist - radius; // signed dist from wave front
// leading edge: Gaussian bell; trailing wake: exponential 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);
float fade = clamp(1.0 - radius / 2.0, 0.0, 1.0); // fade as ring exits viewport
float disp = rings * band * uAmplitude * fade;
// displace radially outward from origin
vec2 dir = normalize(uv - origin + 0.0001);
vec2 dispUv = uv + dir * disp;
// Ken Burns slow zoom per slide
vec2 uvPrev = zoomUv(dispUv, uKenBurnsPrev, origin);
vec2 uvCurr = zoomUv(dispUv, uKenBurnsCurr, origin);
uvPrev = clamp(uvPrev, 0.0, 1.0);
uvCurr = clamp(uvCurr, 0.0, 1.0);
vec4 prevColor = texture2D(uPrev, uvPrev);
vec4 currColor = texture2D(uCurr, uvCurr);
// radial mask: reveals incoming slide from origin outward
float maxDist = sqrt(aspect * aspect + 1.0) * 0.75; // covers farthest corner
float maskRadius = uT * maxDist;
float halfFeather = uFeather * maxDist * 0.5;
float maskAlpha = smoothstep(maskRadius + halfFeather, maskRadius - halfFeather, dist);
gl_FragColor = mix(prevColor, currColor, maskAlpha);
}
`
// ── Helpers ───────────────────────────────────────────────────────────────────
function easeIn(t: number): number {
return Math.pow(t, 1.8) // approximates cubic-bezier(0.4, 0, 1, 1)
}
function parseCssTime(raw: string, fallback: number): number {
const s = raw.trim()
if (!s) return fallback
if (s.endsWith('ms')) return parseFloat(s)
if (s.endsWith('s')) return parseFloat(s) * 1000
return fallback
}
function parseCssPct(raw: string, fallback: number): number {
const s = raw.trim()
return s.endsWith('%') ? parseFloat(s) / 100 : fallback
}
function parseCssNum(raw: string, fallback: number): number {
const n = parseFloat(raw.trim())
return isNaN(n) ? fallback : n
}
// ── RippleSlideshow ───────────────────────────────────────────────────────────
class RippleSlideshow {
private gl: WebGLRenderingContext
private canvas: HTMLCanvasElement
private container: HTMLElement
private textures: WebGLTexture[]
private srcs: string[]
private U: Record
private current = 0
private animStart = -Infinity
private isAnimating = false
private kbStart = -Infinity
private kbStartPrev = -Infinity
private animDuration: number
private delay: number
private kbDuration: number
constructor(container: HTMLElement) {
this.container = container
this.srcs = Array.from(container.querySelectorAll('img')).map(img => img.src)
const cs = getComputedStyle(container)
this.animDuration = parseCssTime(cs.getPropertyValue('--duration'), 1000)
this.delay = parseCssTime(cs.getPropertyValue('--delay'), 5000)
this.kbDuration = parseCssTime(cs.getPropertyValue('--ken-burns-duration'), 10000)
container.innerHTML = ''
container.style.overflow = 'hidden'
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'
container.appendChild(this.canvas)
this.gl = this.canvas.getContext('webgl')!
const prog = this.createProgram()
this.gl.useProgram(prog)
this.setupQuad(prog)
this.U = this.collectUniforms(prog)
this.textures = this.srcs.map(() => this.createTexture())
new ResizeObserver(() => this.resize()).observe(container)
this.resize()
}
private compileShader(type: number, src: string): WebGLShader {
const shader = this.gl.createShader(type)!
this.gl.shaderSource(shader, src)
this.gl.compileShader(shader)
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS))
throw new Error(this.gl.getShaderInfoLog(shader) ?? 'shader compile error')
return shader
}
private createProgram(): WebGLProgram {
const prog = this.gl.createProgram()!
this.gl.attachShader(prog, this.compileShader(this.gl.VERTEX_SHADER, VS))
this.gl.attachShader(prog, this.compileShader(this.gl.FRAGMENT_SHADER, FS))
this.gl.linkProgram(prog)
if (!this.gl.getProgramParameter(prog, this.gl.LINK_STATUS))
throw new Error(this.gl.getProgramInfoLog(prog) ?? 'program link error')
return prog
}
private setupQuad(prog: WebGLProgram) {
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1])
const buf = this.gl.createBuffer()!
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buf)
this.gl.bufferData(this.gl.ARRAY_BUFFER, quad, this.gl.STATIC_DRAW)
const loc = this.gl.getAttribLocation(prog, 'position')
this.gl.enableVertexAttribArray(loc)
this.gl.vertexAttribPointer(loc, 2, this.gl.FLOAT, false, 0, 0)
}
private collectUniforms(prog: WebGLProgram): Record {
const names = ['uPrev','uCurr','uOrigin','uResolution','uT','uFeather',
'uAmplitude','uRingCount','uRingWidth','uRingLength',
'uRingSpeed','uDecay','uKenBurnsCurr','uKenBurnsPrev']
return Object.fromEntries(names.map(n => [n, this.gl.getUniformLocation(prog, n)]))
}
private createTexture(): WebGLTexture {
const gl = this.gl
const tex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, tex)
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)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
return tex
}
private loadImage(tex: WebGLTexture, src: string): Promise {
return new Promise(resolve => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const gl = this.gl
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1) // WebGL Y=0 is bottom; flip to match CSS origin convention
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)
resolve()
}
img.onerror = () => resolve()
img.src = src
})
}
private resize() {
const dpr = Math.min(devicePixelRatio, 2) // cap at 2x to avoid overdraw on high-DPR screens
this.canvas.width = this.container.clientWidth * dpr
this.canvas.height = this.container.clientHeight * dpr
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height)
}
private readConfig() {
const cs = getComputedStyle(this.container)
return {
originX: parseCssPct(cs.getPropertyValue('--origin-x'), 0.5),
originY: parseCssPct(cs.getPropertyValue('--origin-y'), 0.5),
feather: parseCssNum(cs.getPropertyValue('--feather'), 0.15),
amplitude: parseCssNum(cs.getPropertyValue('--distort-scale'), 40) / 1000,
ringCount: parseCssNum(cs.getPropertyValue('--ring-count'), 3),
ringWidth: parseCssNum(cs.getPropertyValue('--ring-width'), 0.12),
ringLength: parseCssNum(cs.getPropertyValue('--ring-length'), 1.0),
ringSpeed: parseCssNum(cs.getPropertyValue('--ring-speed'), 0.8),
decay: parseCssNum(cs.getPropertyValue('--decay'), 0),
}
}
private draw(now: number) {
const gl = this.gl
const { originX, originY, feather, amplitude,
ringCount, ringWidth, ringLength, ringSpeed, decay } = this.readConfig()
let t = 1
if (this.isAnimating) {
const rawT = Math.min((now - this.animStart) / this.animDuration, 1)
t = easeIn(rawT)
if (rawT >= 1) this.isAnimating = false
}
const n = this.srcs.length
const curr = this.current
const prev = (this.current - 1 + n) % n
const kbElapsed = (now - this.kbStart) / this.kbDuration
const kbElapsedPrev = (now - this.kbStartPrev) / this.kbDuration
const kenCurr = Math.min(kbElapsed, 1) * 0.1
const kenPrev = Math.min(kbElapsedPrev, 1) * 0.1
// idle: both textures = curr at uT=1 → full curr with kenCurr zoom
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, this.textures[this.isAnimating ? prev : curr])
gl.uniform1i(this.U.uPrev, 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, this.textures[curr])
gl.uniform1i(this.U.uCurr, 1)
gl.uniform2f(this.U.uOrigin, originX, originY)
gl.uniform2f(this.U.uResolution, this.canvas.width, this.canvas.height)
gl.uniform1f(this.U.uT, this.isAnimating ? t : 1)
gl.uniform1f(this.U.uFeather, feather)
gl.uniform1f(this.U.uAmplitude, this.isAnimating ? amplitude : 0) // kill distortion when idle
gl.uniform1f(this.U.uRingCount, ringCount)
gl.uniform1f(this.U.uRingWidth, ringWidth)
gl.uniform1f(this.U.uRingLength, ringLength)
gl.uniform1f(this.U.uRingSpeed, ringSpeed)
gl.uniform1f(this.U.uDecay, decay)
gl.uniform1f(this.U.uKenBurnsCurr, kenCurr)
gl.uniform1f(this.U.uKenBurnsPrev, kenPrev)
gl.drawArrays(gl.TRIANGLES, 0, 6)
requestAnimationFrame(t => this.draw(t))
}
private advance() {
this.kbStartPrev = this.kbStart // outgoing keeps its running Ken Burns clock
this.current = (this.current + 1) % this.srcs.length
this.animStart = performance.now()
this.kbStart = performance.now() // incoming starts fresh from 0
this.isAnimating = true
}
async start() {
await Promise.all(this.srcs.map((src, i) => this.loadImage(this.textures[i], src)))
this.kbStart = this.kbStartPrev = performance.now()
requestAnimationFrame(t => this.draw(t))
setInterval(() => this.advance(), this.delay)
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.slideshow-ripple-reveal-webgl').forEach(el => {
if (el.querySelectorAll('img').length < 2) return
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
new RippleSlideshow(el).start()
}
})
})
```