"use client";
import * as React from "react";
export type WaterRipplePattern = "tiles" | "plain";
export interface WaterRippleProps extends React.ComponentPropsWithoutRef<"div"> {
/** Energy kept per simulation step (0.9–0.995). Higher values let ripples travel further. */
damping?: number;
/** Simulation cells per CSS pixel (0.1–0.5). The height map is upscaled to the display. */
resolution?: number;
/** Radius of a pointer drop, in CSS px. */
dropRadius?: number;
/** How far the surface slope displaces the background, in CSS px. */
refraction?: number;
/** Let idle raindrops fall on the surface. */
rain?: boolean;
/** Procedural floor drawn beneath the water when no `src` is given. */
pattern?: WaterRipplePattern;
/** Water tint. Mixed toward near-black on dark themes and toward paper white on light themes. */
color?: string;
/** Specular highlight colour. */
highlight?: string;
/** Optional image to refract instead of the gradient (must be same-origin or CORS-enabled). */
src?: string;
/** Drop ripples on pointer move, click and tap. */
interactive?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input;
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
const mix = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
const css = ([r, g, b]: RGB, alpha = 1) => `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha})`;
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
const MAX_CELLS = 160_000;
const STEP_MS = 1000 / 60;
const QUIET_FRAMES = 24;
/**
* A water surface simulated with the classic two-buffer height map (Hugo Elias) at a reduced
* resolution. Each frame the slope of the height map refracts a background buffer (a tinted
* gradient with an optional tile floor, or an image) and adds specular light, then the small
* frame is upscaled with smoothing onto a DPR-aware canvas. The loop sleeps when the water is
* calm, offscreen or in a hidden tab.
*/
export function WaterRipple({
damping = 0.975,
resolution = 0.25,
dropRadius = 14,
refraction = 22,
rain = true,
pattern = "tiles",
color = "#B6F3D1",
highlight = "#FFFFFF",
src,
interactive = true,
className = "",
children,
...props
}: WaterRippleProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
// Small canvas holding one simulation frame, and one holding the (unrefracted) background.
const sim = document.createElement("canvas");
const sctx = sim.getContext("2d");
const bgCanvas = document.createElement("canvas");
const bctx = bgCanvas.getContext("2d", { willReadFrequently: true });
if (!sctx || !bctx) return;
const keep = Math.min(0.995, Math.max(0.9, damping));
const scale = Math.min(0.5, Math.max(0.1, resolution));
let width = 0;
let height = 0;
let dpr = 1;
let sw = 0;
let sh = 0;
let cellScale = scale; // cells per CSS px after the MAX_CELLS cap
let cur = new Float32Array(0);
let prev = new Float32Array(0);
let bg = new Uint8ClampedArray(0);
let frameImage: ImageData | null = null;
let image: HTMLImageElement | null = null;
let spec: RGB = [255, 255, 255];
let dark = false;
let raf = 0;
let last = 0;
let pending = 0;
let inView = false;
let quiet = 0;
let nextRain = 600;
const ptr = { x: 0, y: 0, lx: 0, ly: 0, moved: false, has: false };
/* ------------------------------ Simulation ------------------------------ */
const step = () => {
let peak = 0;
for (let y = 1; y < sh - 1; y++) {
let i = y * sw + 1;
for (let x = 1; x < sw - 1; x++, i++) {
const v = ((cur[i - 1] + cur[i + 1] + cur[i - sw] + cur[i + sw]) * 0.5 - prev[i]) * keep;
prev[i] = v;
const a = v < 0 ? -v : v;
if (a > peak) peak = a;
}
}
const t = cur;
cur = prev;
prev = t;
return peak;
};
/** Adds a cosine-shaped dent at a CSS-pixel position. */
const drop = (cssX: number, cssY: number, radiusPx: number, strength: number) => {
if (!sw) return;
const cx = cssX * cellScale;
const cy = cssY * cellScale;
const r = Math.max(1.5, radiusPx * cellScale);
const x0 = Math.max(1, Math.floor(cx - r));
const x1 = Math.min(sw - 2, Math.ceil(cx + r));
const y0 = Math.max(1, Math.floor(cy - r));
const y1 = Math.min(sh - 2, Math.ceil(cy + r));
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const d = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
if (d < r) cur[y * sw + x] -= strength * 0.5 * (1 + Math.cos((Math.PI * d) / r));
}
}
quiet = 0;
};
/* ------------------------------ Background ------------------------------ */
const buildBackground = () => {
if (!sw) return;
bgCanvas.width = sw;
bgCanvas.height = sh;
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
dark = luminance(ink) > 0.5;
const tint = resolveColor(color, [182, 243, 209]);
spec = resolveColor(highlight, [255, 255, 255]);
const base: RGB = dark ? [9, 12, 14] : [250, 249, 245];
const shallow = mix(tint, base, dark ? 0.72 : 0.62);
const deep = mix(tint, base, dark ? 0.86 : 0.5);
const deepest = dark ? mix(deep, [0, 0, 0], 0.35) : mix(mix(tint, base, 0.3), [20, 40, 44], 0.25);
let usedImage = false;
if (image) {
// Cover-fit the image into the simulation-sized buffer.
const ratio = Math.max(sw / image.naturalWidth, sh / image.naturalHeight);
const w = image.naturalWidth * ratio;
const h = image.naturalHeight * ratio;
bctx.drawImage(image, (sw - w) / 2, (sh - h) / 2, w, h);
try {
bg = bctx.getImageData(0, 0, sw, sh).data;
usedImage = true;
} catch {
image = null; // tainted by a cross-origin image without CORS: fall back to the gradient
}
}
if (!usedImage) {
const grad = bctx.createLinearGradient(0, 0, sw * 0.35, sh);
grad.addColorStop(0, css(shallow));
grad.addColorStop(0.55, css(deep));
grad.addColorStop(1, css(deepest));
bctx.fillStyle = grad;
bctx.fillRect(0, 0, sw, sh);
if (pattern === "tiles") {
const tile = Math.max(6, 44 * cellScale);
const line = mix(deepest, dark ? [0, 0, 0] : [10, 30, 34], 0.35);
bctx.fillStyle = css(line, dark ? 0.3 : 0.14);
const t = Math.max(1, Math.round(1.6 * cellScale * 2) / 2);
for (let x = (sw % tile) / 2; x < sw; x += tile) bctx.fillRect(Math.round(x), 0, t, sh);
for (let y = (sh % tile) / 2; y < sh; y += tile) bctx.fillRect(0, Math.round(y), sw, t);
}
// Gentle vignette for depth.
const vig = bctx.createRadialGradient(sw / 2, sh * 0.4, Math.min(sw, sh) * 0.2, sw / 2, sh / 2, Math.max(sw, sh) * 0.75);
vig.addColorStop(0, "rgba(0, 0, 0, 0)");
vig.addColorStop(1, dark ? "rgba(0, 0, 0, 0.35)" : "rgba(20, 40, 44, 0.12)");
bctx.fillStyle = vig;
bctx.fillRect(0, 0, sw, sh);
bg = bctx.getImageData(0, 0, sw, sh).data;
}
};
/* -------------------------------- Render -------------------------------- */
const render = () => {
if (!frameImage || bg.length !== sw * sh * 4) return;
const out = frameImage.data;
const refr = refraction * cellScale;
const shine = dark ? 0.9 : 0.75;
const shadow = dark ? 0.45 : 0.3;
const [hr, hg, hb] = spec;
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
const i = y * sw + x;
const o = i * 4;
if (x === 0 || y === 0 || x === sw - 1 || y === sh - 1) {
out[o] = bg[o];
out[o + 1] = bg[o + 1];
out[o + 2] = bg[o + 2];
out[o + 3] = 255;
continue;
}
const dx = cur[i - 1] - cur[i + 1];
const dy = cur[i - sw] - cur[i + sw];
// Bilinear sample of the background at the refracted position.
let fx = x + dx * refr;
let fy = y + dy * refr;
fx = fx < 0 ? 0 : fx > sw - 1.001 ? sw - 1.001 : fx;
fy = fy < 0 ? 0 : fy > sh - 1.001 ? sh - 1.001 : fy;
const ix = fx | 0;
const iy = fy | 0;
const tx = fx - ix;
const ty = fy - iy;
const b00 = (iy * sw + ix) * 4;
const b10 = b00 + 4;
const b01 = b00 + sw * 4;
const b11 = b01 + 4;
const w00 = (1 - tx) * (1 - ty);
const w10 = tx * (1 - ty);
const w01 = (1 - tx) * ty;
const w11 = tx * ty;
let r = bg[b00] * w00 + bg[b10] * w10 + bg[b01] * w01 + bg[b11] * w11;
let g = bg[b00 + 1] * w00 + bg[b10 + 1] * w10 + bg[b01 + 1] * w01 + bg[b11 + 1] * w11;
let bl = bg[b00 + 2] * w00 + bg[b10 + 2] * w10 + bg[b01 + 2] * w01 + bg[b11 + 2] * w11;
// Light from the top-left: slopes facing it brighten toward the highlight, others darken.
const light = (dx + dy) * shine;
if (light > 0) {
const k = light > 1 ? 1 : light * light * (3 - 2 * light);
r += (hr - r) * k;
g += (hg - g) * k;
bl += (hb - bl) * k;
} else if (light < 0) {
const k = 1 - (light < -1 ? 1 : -light) * shadow;
r *= k;
g *= k;
bl *= k;
}
out[o] = r;
out[o + 1] = g;
out[o + 2] = bl;
out[o + 3] = 255;
}
}
sctx.putImageData(frameImage, 0, 0);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(sim, 0, 0, canvas.width, canvas.height);
};
/* --------------------------------- Loop --------------------------------- */
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : STEP_MS);
last = now;
if (interactive && ptr.moved) {
// Dents along the pointer path, scaled by how far it travelled this frame.
const dist = Math.hypot(ptr.x - ptr.lx, ptr.y - ptr.ly);
const n = Math.min(4, Math.ceil(dist / (dropRadius * 0.8)));
const strength = Math.min(0.5, 0.05 + dist / 120);
for (let k = 1; k <= n; k++) {
const t = k / n;
drop(ptr.lx + (ptr.x - ptr.lx) * t, ptr.ly + (ptr.y - ptr.ly) * t, dropRadius * 0.6, strength / n);
}
ptr.lx = ptr.x;
ptr.ly = ptr.y;
ptr.moved = false;
}
if (rain) {
nextRain -= dt;
if (nextRain <= 0) {
drop(Math.random() * width, Math.random() * height, dropRadius * (0.4 + Math.random() * 0.5), 0.45 + Math.random() * 0.5);
nextRain = 350 + Math.random() * 1100;
}
}
// Fixed 60Hz steps keep the wave speed identical on 60, 120 and 144Hz displays.
pending = Math.min(pending + dt, STEP_MS * 3);
let peak = 0;
let stepped = false;
while (pending >= STEP_MS) {
peak = Math.max(peak, step());
pending -= STEP_MS;
stepped = true;
}
if (stepped) {
render();
quiet = peak < 0.002 ? quiet + 1 : 0;
}
schedule();
};
function schedule() {
const calm = !rain && quiet > QUIET_FRAMES;
if (!raf && inView && !reduceMotion && !document.hidden && !calm) raf = requestAnimationFrame(frame);
}
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
/** Reduced motion: a still surface with a few frozen rings for texture. */
const drawStill = () => {
drop(width * 0.3, height * 0.4, dropRadius * 1.4, 1.4);
drop(width * 0.72, height * 0.62, dropRadius * 1.2, 1.2);
for (let n = 0; n < 46; n++) step();
render();
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && sw > 0) return;
width = Math.max(1, rect.width);
height = Math.max(1, rect.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
cellScale = scale;
if (width * height * cellScale * cellScale > MAX_CELLS) cellScale = Math.sqrt(MAX_CELLS / (width * height));
sw = Math.max(3, Math.ceil(width * cellScale));
sh = Math.max(3, Math.ceil(height * cellScale));
sim.width = sw;
sim.height = sh;
cur = new Float32Array(sw * sh);
prev = new Float32Array(sw * sh);
frameImage = sctx.createImageData(sw, sh);
buildBackground();
if (reduceMotion) drawStill();
else render();
schedule();
};
const wake = () => {
quiet = 0;
schedule();
};
/* -------------------------------- Input --------------------------------- */
const local = (e: PointerEvent) => {
const rect = root.getBoundingClientRect();
return [e.clientX - rect.left, e.clientY - rect.top] as const;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
const [x, y] = local(e);
if (!ptr.has) {
ptr.lx = x;
ptr.ly = y;
ptr.has = true;
}
ptr.x = x;
ptr.y = y;
ptr.moved = true;
wake();
};
const onPointerLeave = () => {
ptr.has = false;
ptr.moved = false;
};
const onPointerDown = (e: PointerEvent) => {
const [x, y] = local(e);
drop(x, y, dropRadius, 1.6);
wake();
};
const onThemeChange = () => {
buildBackground();
if (!raf) render();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) schedule();
else stop();
});
if (src) {
const img = new Image();
img.crossOrigin = "anonymous";
img.decoding = "async";
img.onload = () => {
image = img;
buildBackground();
if (reduceMotion) drawStill();
else if (!raf) render();
};
img.src = src;
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
const listen = interactive && !reduceMotion;
if (listen) {
root.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerleave", onPointerLeave);
root.addEventListener("pointerdown", onPointerDown, { passive: true });
}
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerleave", onPointerLeave);
root.removeEventListener("pointerdown", onPointerDown);
};
}, [damping, resolution, dropRadius, refraction, rain, pattern, color, highlight, src, interactive, reduceMotion]);
return (
<div
ref={rootRef}
className={`absolute inset-0 overflow-hidden bg-[#E9F3EE] dark:bg-[#0B1113] ${className}`}
{...props}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}