Image Effects
Cursor- and scroll-driven image effects — trails, an infinite field and ripple refraction.
Image Ripple Effect — An image rendered with raw WebGL that refracts under interactive ripple waves following the cursor; falls back to a procedural token-themed texture when no src is given.
Preview
<div class="h-72 w-full max-w-md overflow-hidden rounded-lg border border-border">
<x-ui.image-ripple-effect alt="Ripple demo" class="size-full" />
</div>
Installation
php artisan ui:add image-ripple-effect
Note
This component ships an Alpine behavior module at
resources/js/ui/image-ripple-effect.js. Import it once from your bundle so it registers on alpine:init:
import './image-ripple-effect.js';
Registry contract
php artisan ui:add image-ripple-effect
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/image-ripple-effect.blade.php -
resources/js/ui/image-ripple-effect.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Image Ripple Effect (`image-ripple-effect`)
An image rendered with raw WebGL that refracts under interactive ripple waves following the cursor; falls back to a procedural token-themed texture when no src is given.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add image-ripple-effect
```
## Usage
```blade
<div class="h-72 w-full max-w-md overflow-hidden rounded-lg border border-border">
<x-ui.image-ripple-effect alt="Ripple demo" class="size-full" />
</div>
```
## Props
- `src` (mixed|null, default `null`) — Image URL loaded into the WebGL texture; when omitted a procedural token-themed texture is generated instead.
- `distortionStrength` (int, default `1`) — Scales how strongly ripples refract the image.
- `waveSize` (int, default `1`) — Scales the initial size of each ripple.
- `waveGrowth` (int, default `1`) — Scales how fast each ripple grows outward.
- `waveFadeMultiplier` (int, default `1`) — Scales how fast each ripple fades out.
- `alt` (string, default ``) — Accessible label describing the rendered image.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Making a hero or feature image ripple with a liquid distortion that follows the pointer.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The image must stay pixel-accurate, selectable, or otherwise plain markup; a WebGL canvas cannot provide either. Render a plain image, or use image-mask for a CSS-only effect, instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:image-ripple-effect>` tag (or `<x-ui.image-ripple-effect>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/image-ripple-effect
- Registry JSON (files, props, contract): https://brokui.dev/r/open/image-ripple-effect.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | mixed | null | null | Image URL loaded into the WebGL texture; when omitted a procedural token-themed texture is generated instead. |
| distortionStrength | int | 1 | Scales how strongly ripples refract the image. |
| waveSize | int | 1 | Scales the initial size of each ripple. |
| waveGrowth | int | 1 | Scales how fast each ripple grows outward. |
| waveFadeMultiplier | int | 1 | Scales how fast each ripple fades out. |
| alt | string | Accessible label describing the rendered image. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders the image onto a full-screen WebGL quad and stamps radial ripple impulses at the pointer as it moves, refracting the image in real time by distortionStrength.
- With no src, a procedural texture is generated from the host's primary, accent, and background tokens at runtime, so the demo ships no image asset.
- Reduced motion shows a static, undistorted frame with no pointer interaction or animation loop.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Support brand expression or a showcase moment.
Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Making a hero or feature image ripple with a liquid distortion that follows the pointer.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The image must stay pixel-accurate, selectable, or otherwise plain markup; a WebGL canvas cannot provide either. Render a plain image, or use image-mask for a CSS-only effect, instead.
Use instead
- Static content-first presentation
Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The canvas carries role=img and an aria-label from the alt prop, with a generic fallback, since the canvas itself is the visible imagery.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="h-72 w-full max-w-md overflow-hidden rounded-lg border border-border">
<x-ui.image-ripple-effect alt="Ripple demo" class="size-full" />
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Image URL to load into the WebGL texture. When null a procedural,
// token-themed texture is generated at runtime so the demo ships no assets.
'src' => null,
// distortionStrength scales the UV refraction; waveSize/waveGrowth size and
// grow each ripple; waveFadeMultiplier scales how fast ripples fade out.
'distortionStrength' => 1,
'waveSize' => 1,
'waveGrowth' => 1,
'waveFadeMultiplier' => 1,
// Accessible label for the rendered image (the canvas is role="img").
'alt' => '',
])
@php
$config = [
'src' => $src,
'distortionStrength' => (float) $distortionStrength,
'waveSize' => (float) $waveSize,
'waveGrowth' => (float) $waveGrowth,
'waveFadeMultiplier' => (float) $waveFadeMultiplier,
];
@endphp
{{--
Image Ripple Effect. A single <canvas> fills the host and shows an image
rendered onto a full-screen quad by a raw WebGL1 program (no Three.js). As
the pointer moves over the element it stamps radial ripple impulses at the
cursor; the fragment shader sums the active ripples into a displacement that
refracts the image UV in real time (distortionStrength), producing a liquid
ripple distortion.
The canvas IS the visible imagery and needs the cursor, so it is NOT
pointer-events-none. It is decorative, so it carries role="img" + an
aria-label (the `alt` prop) for assistive tech.
With no `src` the JS generates a procedural texture from the host's computed
--primary / --accent / --background tokens, so markup stays token-only and
the demo is self-contained. Reduced-motion users get a static, undistorted
frame with no pointer distortion and no animation loop.
--}}
<div
data-slot="image-ripple-effect"
x-data="uiImageRippleEffect(@js($config))"
x-ref="root"
{{ $attributes->merge(['class' => 'relative isolate block overflow-hidden bg-muted']) }}
>
<canvas
x-ref="canvas"
role="img"
aria-label="{{ $alt !== '' ? $alt : __('Image with interactive ripple distortion') }}"
class="block h-full w-full touch-none select-none"
></canvas>
</div>
/**
* Image Ripple Effect behavior (raw WebGL1 interactive ripple distortion).
*
* A single <canvas> fills the host element and shows an image rendered onto a
* full-screen quad by a hand-written WebGL1 / GLSL ES 1.00 program — no
* Three.js, no npm. As the pointer moves over the element it stamps radial
* "brush" ripple impulses at the cursor; each ripple grows (`waveGrowth`,
* scaled by `waveSize`) and fades (`waveFadeMultiplier`) over its lifetime. The
* fragment shader sums the active ripples into a displacement vector and offsets
* the texture UV by it (scaled by `distortionStrength`), refracting the image in
* real time for a liquid ripple look.
*
* Ripples are kept in a small JS array of {x, y, age, born}; up to MAX_RIPPLES
* of them are uploaded each frame as a vec4[] uniform (x, y in 0..1 UV space,
* radius, strength). The newest ripples replace the oldest once the pool fills.
*
* Image source: the `src` config is an image URL loaded into a WebGL texture
* (crossOrigin='anonymous', async — the texture is (re)uploaded and a frame
* re-rendered on load). When `src` is null a procedural, token-themed texture is
* generated in an offscreen 2D canvas (a gradient between --primary / --accent /
* --background read via a getComputedStyle rgb-probe, plus soft radial shapes)
* and uploaded instead — so the demo is self-contained and ships no assets.
*
* Sizing: the backing store is scaled for devicePixelRatio and a ResizeObserver
* keeps the viewport + u_resolution in sync. The image is cover-fit to the quad
* via u_imageSize / u_resolution in the shader. WebGL context loss is handled:
* the rAF stops on `webglcontextlost`, and the program + texture are rebuilt and
* the loop restarts on `webglcontextrestored`.
*
* a11y / reduced motion: the canvas carries role="img" + aria-label (set in
* Blade) because it IS the visible imagery (it needs the cursor, so it is NOT
* pointer-events-none). Under prefers-reduced-motion a single STATIC, undistorted
* frame is drawn with no rAF, no pointer listener and no ripples.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
const MAX_RIPPLES = 12;
window.Alpine.data('uiImageRippleEffect', (config = {}) => ({
src: config.src ?? null,
distortionStrength: Number.isFinite(+config.distortionStrength) ? +config.distortionStrength : 1,
waveSize: Number.isFinite(+config.waveSize) ? +config.waveSize : 1,
waveGrowth: Number.isFinite(+config.waveGrowth) ? +config.waveGrowth : 1,
waveFadeMultiplier: Number.isFinite(+config.waveFadeMultiplier) ? +config.waveFadeMultiplier : 1,
// Non-reactive GL handles kept off Alpine's proxy.
_gl: null,
_canvas: null,
_program: null,
_buffer: null,
_texture: null,
_loc: null,
_dpr: 1,
_raf: 0,
_start: 0,
_last: 0,
_ro: null,
_reduced: false,
_ripples: [],
// Reused per-frame ripple upload buffer (allocated once in init, never
// re-allocated in the hot render path). Only [0, count*4) is written
// each frame; the shader reads only up to u_count so stale tail entries
// are never sampled.
_rippleData: null,
_imageW: 1,
_imageH: 1,
_onLost: null,
_onRestored: null,
_onPointer: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
if (!this._canvas) return;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._rippleData = new Float32Array(MAX_RIPPLES * 4);
this._onLost = (e) => {
e.preventDefault();
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0; }
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0; }
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
if (this._onPointer) this._canvas.removeEventListener('pointermove', this._onPointer, false);
}
const gl = this._gl;
if (gl) {
if (this._buffer) gl.deleteBuffer(this._buffer);
if (this._texture) gl.deleteTexture(this._texture);
if (this._program) gl.deleteProgram(this._program);
const lose = gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
}
this._gl = this._program = this._buffer = this._texture = this._loc = null;
this._ripples = [];
},
// (Re)create the GL context + program + texture and start rendering.
// Safe to call again after a context restore.
boot() {
const gl = this._canvas.getContext('webgl', { premultipliedAlpha: false, antialias: false })
|| this._canvas.getContext('experimental-webgl');
if (!gl) return; // No WebGL: leave the host as-is.
this._gl = gl;
this._program = this.buildProgram(gl);
if (!this._program) return;
this._buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(this._program, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
this._loc = {
resolution: gl.getUniformLocation(this._program, 'u_resolution'),
imageSize: gl.getUniformLocation(this._program, 'u_imageSize'),
texture: gl.getUniformLocation(this._program, 'u_texture'),
strength: gl.getUniformLocation(this._program, 'u_strength'),
count: gl.getUniformLocation(this._program, 'u_count'),
ripples: gl.getUniformLocation(this._program, 'u_ripples'),
};
gl.useProgram(this._program);
gl.uniform1i(this._loc.texture, 0);
// Start with a 1x1 placeholder so the first frame is valid before
// the real image / procedural texture is ready.
this._texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
new Uint8Array([20, 20, 24, 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);
this.resize();
this.loadTexture();
if (this._reduced) {
// Static, undistorted frame; no rAF, no pointer, no ripples.
this.render();
} else {
this._onPointer = (e) => this.stamp(e);
this._canvas.addEventListener('pointermove', this._onPointer, false);
let pending = false;
this._ro = new ResizeObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => { pending = false; this.resize(); });
});
this._ro.observe(this.$refs.root);
this._start = performance.now();
this._last = this._start;
this._raf = requestAnimationFrame((t) => this.loop(t));
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Guarded so a context restore (boot re-run) reuses the observer.
if (!this._viewObserver && typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = 0;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._last = performance.now();
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
}
},
buildProgram(gl) {
const vsSource = [
'attribute vec2 a_position;',
'void main() { gl_Position = vec4(a_position, 0.0, 1.0); }',
].join('\n');
const fsSource = [
'precision highp float;',
'uniform vec2 u_resolution;',
'uniform vec2 u_imageSize;',
'uniform sampler2D u_texture;',
'uniform float u_strength;',
'uniform int u_count;',
// Each ripple: xy = center in UV (0..1), z = radius, w = strength.
'uniform vec4 u_ripples[' + MAX_RIPPLES + '];',
'',
'void main() {',
' vec2 uv = gl_FragCoord.xy / u_resolution.xy;',
' uv.y = 1.0 - uv.y;', // flip to image space
'',
' // Cover-fit: scale UV so the image fills the quad without',
' // distortion, centered, cropping the overflowing axis.',
' float canvasAspect = u_resolution.x / max(u_resolution.y, 1.0);',
' float imageAspect = u_imageSize.x / max(u_imageSize.y, 1.0);',
' vec2 scale = vec2(1.0);',
' if (canvasAspect > imageAspect) {',
' scale.y = imageAspect / canvasAspect;',
' } else {',
' scale.x = canvasAspect / imageAspect;',
' }',
' vec2 cover = (uv - 0.5) * scale + 0.5;',
'',
' // Sum radial displacement from each active ripple. A ripple',
' // pushes nearby texels outward along the radius within a soft',
' // ring at its current radius, weighted by its strength.',
' vec2 disp = vec2(0.0);',
' for (int i = 0; i < ' + MAX_RIPPLES + '; i++) {',
' if (i >= u_count) break;',
' vec4 r = u_ripples[i];',
' vec2 d = uv - r.xy;',
' d.x *= canvasAspect;', // isotropic distance in canvas space
' float dist = length(d);',
' float ring = exp(-pow((dist - r.z) * 12.0, 2.0));',
' vec2 dir = dist > 0.0001 ? d / dist : vec2(0.0);',
' dir.x /= canvasAspect;',
' disp += dir * ring * r.w * 0.06;',
' }',
'',
' vec2 sampleUv = cover + disp * u_strength;',
' // Subtle highlight along the wave for a refractive sheen.',
' float sheen = clamp(length(disp) * 6.0, 0.0, 0.35);',
' vec4 col = texture2D(u_texture, clamp(sampleUv, 0.0, 1.0));',
' col.rgb += sheen;',
' gl_FragColor = vec4(col.rgb, 1.0);',
'}',
].join('\n');
const vs = this.compile(gl, gl.VERTEX_SHADER, vsSource);
const fs = this.compile(gl, gl.FRAGMENT_SHADER, fsSource);
if (!vs || !fs) return null;
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return null;
}
return program;
},
compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return null;
}
return shader;
},
// Load the src image (async) or generate the procedural fallback.
loadTexture() {
if (this.src) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
this._imageW = img.naturalWidth || 1;
this._imageH = img.naturalHeight || 1;
this.uploadTexture(img);
if (this._reduced) this.render();
};
img.onerror = () => this.uploadProcedural();
img.src = this.src;
} else {
this.uploadProcedural();
}
},
uploadTexture(source) {
const gl = this._gl;
if (!gl || !this._texture || gl.isContextLost()) return;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.useProgram(this._program);
gl.uniform2f(this._loc.imageSize, this._imageW, this._imageH);
},
// Procedural token-themed texture drawn into an offscreen 2D canvas.
// Colors are read from the host's computed tokens so it tracks the theme.
uploadProcedural() {
const size = 512;
const off = document.createElement('canvas');
off.width = size;
off.height = size;
const ctx = off.getContext('2d');
// The procedural texture is a stand-in for a real photo (consumers
// pass `src`), so every stop is derived from semantic tokens and
// follows theme / surface changes without editing this component.
const primary = this.tokenColor('--primary');
const accent = this.tokenColor('--accent');
const background = this.tokenColor('--background');
const grad = ctx.createLinearGradient(0, 0, size, size);
grad.addColorStop(0, primary);
grad.addColorStop(0.55, accent);
grad.addColorStop(1, background);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
// Soft radial shapes for visual interest the ripple can refract.
const blob = (cx, cy, rad, color, alpha) => {
const rg = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
rg.addColorStop(0, color);
rg.addColorStop(1, 'transparent');
ctx.globalAlpha = alpha;
ctx.fillStyle = rg;
ctx.fillRect(0, 0, size, size);
};
blob(size * 0.28, size * 0.32, size * 0.42, accent, 0.55);
blob(size * 0.72, size * 0.7, size * 0.5, primary, 0.5);
blob(size * 0.66, size * 0.26, size * 0.3, background, 0.45);
ctx.globalAlpha = 1;
this._imageW = size;
this._imageH = size;
this.uploadTexture(off);
if (this._reduced) this.render();
},
// Probe a CSS custom property to a concrete rgb() string via a throwaway
// element. This token system stores tokens as HSL channel triplets
// (e.g. `--primary: 222 47% 11%`), so the value must be wrapped in
// `hsl(...)` to become a valid colour; we try that first, then the raw
// var (for hex/oklch/named tokens), then the element's computed color.
tokenColor(name) {
const root = this.$refs.root;
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;width:0;height:0;';
root.appendChild(probe);
for (const expr of [`hsl(var(${name}))`, `var(${name})`]) {
probe.style.color = '';
probe.style.color = expr;
const resolved = getComputedStyle(probe).color;
if (/^rgb/.test(resolved)) {
probe.remove();
return resolved;
}
}
probe.remove();
return getComputedStyle(root).color;
},
// Stamp a fresh ripple at the pointer (UV 0..1, origin top-left). The
// pool replaces the oldest entry once full.
stamp(e) {
const rect = this._canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
const ripple = { x, y, born: performance.now() };
if (this._ripples.length >= MAX_RIPPLES) this._ripples.shift();
this._ripples.push(ripple);
// Wake the loop if it idle-parked with no live ripples (and we're on
// screen — a pointer over the canvas already implies visible).
if (!this._raf && !this._offscreen && !this._reduced) {
this._last = performance.now();
this._raf = requestAnimationFrame((t) => this.loop(t));
}
},
resize() {
const gl = this._gl;
if (!gl) return;
const root = this.$refs.root;
this._dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round((root.clientWidth || 1) * this._dpr));
const h = Math.max(1, Math.round((root.clientHeight || 1) * this._dpr));
if (this._canvas.width !== w || this._canvas.height !== h) {
this._canvas.width = w;
this._canvas.height = h;
}
gl.viewport(0, 0, w, h);
if (this._loc) {
gl.useProgram(this._program);
gl.uniform2f(this._loc.resolution, w, h);
}
if (this._reduced) this.render();
},
loop(now) {
this._last = now;
// Lifetime ~1.6s, scaled down by faster fade. Drop dead ripples.
const life = 1600 / Math.max(0.1, this.waveFadeMultiplier);
this._ripples = this._ripples.filter((r) => now - r.born < life);
this.render();
// Idle-park: once every ripple has faded there is nothing left to
// animate, so draw one final settled frame and stop rescheduling.
// stamp() (pointer) and the IntersectionObserver re-entry wake it.
if (this._ripples.length === 0) {
this._raf = 0;
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
render() {
const gl = this._gl;
if (!gl || !this._program || gl.isContextLost()) return;
gl.useProgram(this._program);
gl.uniform1f(this._loc.strength, this.distortionStrength);
const now = this._last || performance.now();
const life = 1600 / Math.max(0.1, this.waveFadeMultiplier);
// Reuse the pre-allocated buffer instead of allocating one per frame.
// Only [0, count*4) is written and the shader reads only up to
// u_count, so any stale tail entries from a prior frame are ignored.
const data = this._rippleData;
let count = 0;
for (let i = 0; i < this._ripples.length && count < MAX_RIPPLES; i++) {
const r = this._ripples[i];
const age = (now - r.born) / life; // 0..1
if (age < 0 || age >= 1) continue;
// Radius grows from the cursor outward; strength fades over life.
const radius = age * 0.55 * this.waveGrowth * this.waveSize;
const strength = (1 - age) * (1 - age);
const o = count * 4;
data[o] = r.x;
data[o + 1] = r.y;
data[o + 2] = radius;
data[o + 3] = strength;
count++;
}
gl.uniform1i(this._loc.count, count);
gl.uniform4fv(this._loc.ripples, data);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._texture);
gl.drawArrays(gl.TRIANGLES, 0, 6);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.5 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0