# 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() } }) }) ```