Skip to content
Brok UI

Loading…

No results

Image Effects

Open source

Cursor- and scroll-driven image effects — trails, an infinite field and ripple refraction.

Version
v1.0.5
Stability
stable
License
MIT
Related
Image Trail
Infinite Image Field

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

previews.components.image-ripple-effect.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/image-ripple-effect.blade.php
  • js resources/js/ui/image-ripple-effect.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

image-ripple-effect.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

image-ripple-effect

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

Expressive and showcase effect

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
root
Theming hooks
image-ripple-effect

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/image-ripple-effect.blade.php Blade
@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>
resources/js/ui/image-ripple-effect.js JS
/**
 * 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