Ripple
Click anywhere and water ripples spread across the page, bending the content like a pond surface. This page is the demo.
Demo image
Click the photos. Refraction and dispersion show best over imagery.
Install
npx shadcn@latest add @canvas-ui/ripple-reactOr copy the source below into your project. Prefer WGSL? Switch the renderer above to the WebGPU build.
Dependencies
The install command above adds these automatically. If you copy the source by hand, install them yourself.
# No dependencies for the WebGL build.Code
"use client";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
export type RippleTrigger = "click" | "hover" | "none";
export interface RippleOptions {
/** Height of the waves (0 to 3). */
amplitude?: number;
/** How fast the rings travel outward. 1 is normal speed. */
speed?: number;
/** Distance between wave crests in CSS pixels. */
wavelength?: number;
/** Number of crests in each wave train (1 to 8). */
rings?: number;
/** How quickly the waves lose energy (higher dies faster). */
decay?: number;
/** How strongly the waves bend the page content, in CSS pixels. */
refraction?: number;
/** Chromatic dispersion splitting colors along the wave slopes (0 to 1). */
dispersion?: number;
/** Intensity of the light glints on the wave crests (0 to 2). */
shine?: number;
/** What spawns ripples. "click" on press, "hover" also leaves a wake while moving, "none" only ambient. */
trigger?: RippleTrigger;
/** Seconds between ambient ripples at random positions. 0 disables them. */
interval?: number;
}
export interface RippleElements {
/** Canvas with layoutsubtree that hosts the HTML content. */
source: HTMLCanvasElement;
/** The element inside the source canvas that gets captured. */
content: HTMLElement;
/** Canvas the WebGL effect renders to. */
output: HTMLCanvasElement;
}
export interface RippleInstance {
/** Update effect options live. */
setOptions: (options: RippleOptions) => void;
/** Spawn a ripple at a position in CSS pixels relative to the element. */
splash: (x: number, y: number, strength?: number) => void;
/** Re-read canvas size. Call when the element is resized. */
resize: () => void;
/** Stop the loop and release all GPU resources. */
destroy: () => void;
}
const DEFAULTS: Required<RippleOptions> = {
amplitude: 0.5,
speed: 0.65,
wavelength: 80,
rings: 2,
decay: 1,
refraction: 100,
dispersion: 0.5,
shine: 0.5,
trigger: "click",
interval: 0,
};
const MAX_RIPPLES = 12;
const BASE_SPEED = 340;
type PaintableCanvas = HTMLCanvasElement & {
onpaint?: (() => void) | null;
requestPaint?: () => void;
};
type ElementImageContext = CanvasRenderingContext2D & {
drawElementImage?: (element: Element, x: number, y: number) => void;
};
const VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
out vec2 vUv;
void main () {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uContent;
uniform vec2 uResolution;
uniform vec4 uRipples[12];
uniform int uCount;
uniform float uSpeed;
uniform float uWavelength;
uniform float uWidth;
uniform float uDecay;
uniform float uRefraction;
uniform float uDispersion;
uniform float uShine;
uniform float uHasContent;
uniform float uMaxX;
vec4 page (vec2 p) {
p.x = clamp(p.x, 0.0005, uMaxX - 0.0005);
p.y = clamp(p.y, 0.0005, 0.9995);
return texture(uContent, p);
}
void main () {
vec2 pUv = vec2(vUv.x, 1.0 - vUv.y);
vec2 frag = pUv * uResolution;
vec2 grad = vec2(0.0);
float k = 6.28318530718 / uWavelength;
float w2 = uWidth * uWidth;
for (int i = 0; i < 12; i++) {
if (i >= uCount) break;
vec4 rp = uRipples[i];
vec2 dv = frag - rp.xy;
float r = length(dv);
float front = uSpeed * rp.z;
float s = r - front;
float env = exp(-s * s / w2) * exp(-uDecay * rp.z) * rp.w;
env *= smoothstep(0.0, 0.08, rp.z);
env *= inversesqrt(1.0 + front / max(uWavelength, 1.0) * 0.2);
if (env < 0.0015) continue;
float dh = (k * cos(s * k) - 2.0 * s / w2 * sin(s * k)) * env;
grad += dv / max(r, 1.0) * dh * uWavelength * 0.16;
}
float g = dot(grad, vec2(-0.55, -0.8));
float glint = pow(clamp(g * 2.2, 0.0, 1.0), 2.0) * uShine;
float shade = pow(clamp(-g * 1.6, 0.0, 1.0), 2.0) * uShine * 0.3;
if (uHasContent < 0.5) {
float a = clamp(glint * 0.9 + shade * 0.5, 0.0, 0.85);
outColor = vec4(vec3(glint * 0.9), a);
return;
}
vec2 offs = grad * uRefraction / uResolution;
vec3 col;
if (uDispersion > 0.001) {
float d = uDispersion * 0.35;
col = vec3(
page(pUv + offs * (1.0 + d)).r,
page(pUv + offs).g,
page(pUv + offs * (1.0 - d)).b
);
} else {
col = page(pUv + offs).rgb;
}
col += glint;
col *= 1.0 - shade;
outColor = vec4(col, 1.0);
}`;
export function supportsHtmlInCanvas(): boolean {
if (typeof document === "undefined") return false;
const probe = document.createElement("canvas") as PaintableCanvas;
const ctx = probe.getContext("2d") as ElementImageContext | null;
return Boolean(
ctx &&
typeof ctx.drawElementImage === "function" &&
typeof probe.requestPaint === "function",
);
}
export function createRipple(
elements: RippleElements,
options: RippleOptions = {},
): RippleInstance | null {
const config = { ...DEFAULTS, ...options };
const { source, content, output } = elements;
const gl = output.getContext("webgl2", {
alpha: true,
depth: false,
stencil: false,
antialias: false,
premultipliedAlpha: true,
});
if (!gl || gl.isContextLost()) return null;
const sourceCtx = source.getContext("2d") as ElementImageContext | null;
const paintable = source as PaintableCanvas;
const htmlInCanvas = Boolean(
sourceCtx &&
typeof sourceCtx.drawElementImage === "function" &&
typeof paintable.requestPaint === "function",
);
let contentDirty = false;
let wake = () => {};
if (htmlInCanvas) {
paintable.onpaint = () => {
try {
sourceCtx!.reset();
sourceCtx!.drawElementImage!(content, 0, 0);
contentDirty = true;
wake();
} catch {}
};
}
function compile(type: number, text: string): WebGLShader {
const shader = gl!.createShader(type)!;
gl!.shaderSource(shader, text);
gl!.compileShader(shader);
if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
console.error("Ripple shader error:", gl!.getShaderInfoLog(shader));
}
return shader;
}
const vertexShader = compile(gl.VERTEX_SHADER, VERT);
const fragmentShader = compile(gl.FRAGMENT_SHADER, FRAG);
const program = gl.createProgram()!;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
const uniforms: Record<string, WebGLUniformLocation> = {};
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < count; i++) {
const info = gl.getActiveUniform(program, i)!;
uniforms[info.name.replace("[0]", "")] = gl.getUniformLocation(
program,
info.name,
)!;
}
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const contentTexture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, contentTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0]),
);
let contentMaxX = 1;
function syncCanvasSize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(output.clientWidth * dpr));
const height = Math.max(1, Math.round(output.clientHeight * dpr));
if (output.width !== width || output.height !== height) {
output.width = width;
output.height = height;
}
contentMaxX = Math.min(
1,
Math.max(0.05, content.clientWidth / Math.max(output.clientWidth, 1)),
);
if (htmlInCanvas) {
const cssWidth = Math.max(1, Math.round(source.clientWidth));
const cssHeight = Math.max(1, Math.round(source.clientHeight));
if (source.width !== cssWidth * dpr|| source.height !== cssHeight * dpr) {
source.width = cssWidth * dpr;
source.height = cssHeight * dpr;
}
paintable.requestPaint!();
}
}
syncCanvasSize();
function uploadContent() {
if (!htmlInCanvas || !contentDirty) return;
contentDirty = false;
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.texImage2D(
gl!.TEXTURE_2D,
0,
gl!.RGBA,
gl!.RGBA,
gl!.UNSIGNED_BYTE,
source,
);
}
type Wave = { x: number; y: number; age: number; amp: number };
const ripples: Wave[] = [];
const rippleData = new Float32Array(MAX_RIPPLES * 4);
function splash(x: number, y: number, strength = 1) {
if (reducedMotion) return;
if (ripples.length >= MAX_RIPPLES) ripples.shift();
ripples.push({ x, y, age: 0, amp: strength });
start();
}
function pruneRipples(delta: number) {
const diag = Math.hypot(output.clientWidth, output.clientHeight);
const speedPx = BASE_SPEED * Math.max(config.speed, 0.05);
const width = config.wavelength * Math.max(config.rings, 1) * 0.5;
for (let i = ripples.length - 1; i >= 0; i--) {
const rp = ripples[i];
rp.age += delta;
const gone =
rp.age * speedPx > diag + width * 3 ||
Math.exp(-Math.max(config.decay, 0.05) * rp.age) * rp.amp < 0.012;
if (gone) ripples.splice(i, 1);
}
}
function render() {
uploadContent();
const dpr = output.width / Math.max(output.clientWidth, 1);
gl!.useProgram(program);
gl!.activeTexture(gl!.TEXTURE0);
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.uniform1i(uniforms.uContent, 0);
gl!.uniform2f(uniforms.uResolution, output.width, output.height);
for (let i = 0; i < MAX_RIPPLES; i++) {
const rp = ripples[i];
rippleData[i * 4] = rp ? rp.x * dpr : 0;
rippleData[i * 4 + 1] = rp ? rp.y * dpr : 0;
rippleData[i * 4 + 2] = rp ? rp.age : 0;
rippleData[i * 4 + 3] = rp ? rp.amp * Math.max(config.amplitude, 0) : 0;
}
gl!.uniform4fv(uniforms.uRipples, rippleData);
gl!.uniform1i(uniforms.uCount, ripples.length);
gl!.uniform1f(uniforms.uSpeed, BASE_SPEED * Math.max(config.speed, 0.05) * dpr);
gl!.uniform1f(uniforms.uWavelength, Math.max(config.wavelength, 4) * dpr);
gl!.uniform1f(
uniforms.uWidth,
Math.max(config.wavelength, 4) * Math.max(config.rings, 1) * 0.5 * dpr,
);
gl!.uniform1f(uniforms.uDecay, Math.max(config.decay, 0.05));
gl!.uniform1f(uniforms.uRefraction, Math.max(config.refraction, 0) * dpr);
gl!.uniform1f(uniforms.uDispersion, Math.max(config.dispersion, 0));
gl!.uniform1f(uniforms.uShine, Math.max(config.shine, 0));
gl!.uniform1f(uniforms.uHasContent, htmlInCanvas ? 1 : 0);
gl!.uniform1f(uniforms.uMaxX, contentMaxX);
gl!.bindFramebuffer(gl!.FRAMEBUFFER, null);
gl!.viewport(0, 0, output.width, output.height);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
}
function renderIdle() {
gl!.bindFramebuffer(gl!.FRAMEBUFFER, null);
gl!.viewport(0, 0, output.width, output.height);
if (htmlInCanvas) {
render();
} else {
gl!.clearColor(0, 0, 0, 0);
gl!.clear(gl!.COLOR_BUFFER_BIT);
}
}
let raf = 0;
let lastTime = performance.now();
let destroyed = false;
let running = false;
let visible = true;
let ambientTimer = 0;
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = motionQuery.matches;
function spawnAmbient() {
const w = output.clientWidth;
const h = output.clientHeight;
if (w < 10 || h < 10) return;
splash(
w * (0.15 + Math.random() * 0.7),
h * (0.15 + Math.random() * 0.7),
0.6 + Math.random() * 0.5,
);
}
function frame(now: number) {
if (destroyed) return;
if (!visible) {
running = false;
return;
}
const delta = Math.min(Math.max((now - lastTime) / 1000, 0), 1 / 30);
lastTime = now;
if (!reducedMotion) {
pruneRipples(delta);
if (config.interval > 0) {
ambientTimer += delta;
if (ambientTimer >= config.interval) {
ambientTimer = 0;
spawnAmbient();
}
}
}
if (ripples.length > 0) {
render();
} else {
renderIdle();
if (!contentDirty && (config.interval <= 0 || reducedMotion)) {
running = false;
return;
}
}
raf = requestAnimationFrame(frame);
}
function start() {
if (destroyed || running || !visible) return;
running = true;
lastTime = performance.now();
raf = requestAnimationFrame(frame);
}
wake = start;
start();
function localPoint(event: PointerEvent): [number, number] {
const rect = output.getBoundingClientRect();
return [event.clientX - rect.left, event.clientY - rect.top];
}
let hoverX = -1e5;
let hoverY = -1e5;
function onPointerDown(event: PointerEvent) {
if (config.trigger === "none") return;
const [x, y] = localPoint(event);
splash(x, y, 1);
}
function onPointerMove(event: PointerEvent) {
if (config.trigger !== "hover") return;
const [x, y] = localPoint(event);
if (Math.hypot(x - hoverX, y - hoverY) < 56) return;
hoverX = x;
hoverY = y;
splash(x, y, 0.3);
}
content.addEventListener("pointerdown", onPointerDown, { passive: true });
content.addEventListener("pointermove", onPointerMove, { passive: true });
function onMotionChange() {
reducedMotion = motionQuery.matches;
if (reducedMotion) ripples.length = 0;
start();
}
motionQuery.addEventListener("change", onMotionChange);
const observer = new ResizeObserver(() => {
syncCanvasSize();
start();
});
observer.observe(output);
observer.observe(content);
const intersection = new IntersectionObserver((entries) => {
visible = entries[entries.length - 1]?.isIntersecting ?? true;
if (visible) start();
});
intersection.observe(output);
return {
setOptions(next) {
if (
!Object.entries(next).some(
([key, value]) => config[key as keyof RippleOptions] !== value,
)
)
return;
Object.assign(config, next);
start();
},
splash,
resize() {
syncCanvasSize();
start();
},
destroy() {
destroyed = true;
cancelAnimationFrame(raf);
content.removeEventListener("pointerdown", onPointerDown);
content.removeEventListener("pointermove", onPointerMove);
observer.disconnect();
intersection.disconnect();
motionQuery.removeEventListener("change", onMotionChange);
gl!.deleteTexture(contentTexture);
gl!.deleteProgram(program);
gl!.deleteShader(vertexShader);
gl!.deleteShader(fragmentShader);
gl!.deleteBuffer(quad);
if (htmlInCanvas) paintable.onpaint = null;
},
};
}
export interface RippleProps extends RippleOptions {
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}
const emptySubscribe = () => () => {};
export function Ripple({ children, className, style, ...options }: RippleProps) {
const sourceRef = useRef<HTMLCanvasElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const outputRef = useRef<HTMLCanvasElement>(null);
const instanceRef = useRef<RippleInstance | null>(null);
const [initialOptions] = useState(options);
const [failed, setFailed] = useState(false);
const supported = useSyncExternalStore(
emptySubscribe,
supportsHtmlInCanvas,
() => false,
);
const native = supported && !failed;
useEffect(() => {
const source = sourceRef.current;
const content = contentRef.current;
const output = outputRef.current;
if (!source || !content || !output) return;
instanceRef.current = createRipple(
{ source, content, output },
initialOptions,
);
if (native && !instanceRef.current) setFailed(true);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, [initialOptions, native]);
useEffect(() => {
instanceRef.current?.setOptions(options);
});
return (
<div className={className} style={{ position: "relative", ...style }}>
<canvas
ref={sourceRef}
// @ts-expect-error experimental html-in-canvas attribute
layoutsubtree="true"
suppressHydrationWarning
style={
native
? { position: "absolute", inset: 0, width: "100%", height: "100%" }
: { display: "none" }
}
>
{native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
</canvas>
{!native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
<canvas
ref={outputRef}
aria-hidden
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
</div>
);
}
export default Ripple;
API reference
| Property | Type | Default |
|---|---|---|
amplitudeHeight of the waves (0 to 3). | number | 0.5 |
speedHow fast the rings travel outward. 1 is normal speed. | number | 0.65 |
wavelengthDistance between wave crests in CSS pixels. | number | 80 |
ringsNumber of crests in each wave train (1 to 8). | number | 2 |
decayHow quickly the waves lose energy (higher dies faster). | number | 1 |
refractionHow strongly the waves bend the page content, in CSS pixels. | number | 100 |
dispersionChromatic dispersion splitting colors along the wave slopes (0 to 1). | number | 0.5 |
shineIntensity of the light glints on the wave crests (0 to 2). | number | 0.5 |
triggerWhat spawns ripples. "click" on press, "hover" also leaves a wake while moving, "none" only ambient. | "click" | "hover" | "none" | "click" |
intervalSeconds between ambient ripples at random positions. 0 disables them. | number | 0 |
classNameClasses applied to the wrapper element. | string |