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.
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 |