"use client";
import { memo, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export interface SubmergeProps {
children?: ReactNode;
depth?: number;
flow?: number;
turbulence?: number;
rippleForce?: number;
rippleSpread?: number;
settle?: number;
edgeHold?: number;
waterColor?: string;
deepColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const RIPPLES = 10;
const TILE = 320;
const FLOW_CELLS = 3;
const SPAWN_FRACTION = 0.09;
const SPRITE_RES = 160;
const WAKE_ANGLES = 16;
const NOSE = 0.45;
const TAIL = 1;
const SIDE = 0.62;
const PEAK_AT = 0.62;
const PEAK_WIDTH = 0.34;
const FLOW_AMP = 100;
const DISPLACE_BASE = 46;
const MAX_WEIGHT = 0.68;
const DEFAULT_GAP = 0.12;
const WAKE_LIFE_MAX = 0.85;
const WAKE_LIFE_MIN = 0.18;
const ringGrowth = (p: number) => (1 - Math.exp(-2.8 * p)) / (1 - Math.exp(-2.8));
const smoothstep = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a || 1e-6)));
return t * t * (3 - 2 * t);
};
function makeNoise(cellsX: number, cellsY: number, seed: number) {
const hash = (ix: number, iy: number) => {
const x = ((ix % cellsX) + cellsX) % cellsX;
const y = ((iy % cellsY) + cellsY) % cellsY;
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
return (u: number, v: number) => {
const fx = u * cellsX;
const fy = v * cellsY;
const ix = Math.floor(fx);
const iy = Math.floor(fy);
const tx = fx - ix;
const ty = fy - iy;
const sx = tx * tx * (3 - 2 * tx);
const sy = ty * ty * (3 - 2 * ty);
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
return (a + (b - a) * sx) * (1 - sy) + (c + (d - c) * sx) * sy;
};
}
function buildFlowTile(): HTMLCanvasElement | null {
const canvas = document.createElement("canvas");
canvas.width = TILE;
canvas.height = TILE;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const nx1 = makeNoise(FLOW_CELLS, FLOW_CELLS, 1);
const ny1 = makeNoise(FLOW_CELLS, FLOW_CELLS, 2);
const nx2 = makeNoise(FLOW_CELLS * 2, FLOW_CELLS * 2, 3);
const ny2 = makeNoise(FLOW_CELLS * 2, FLOW_CELLS * 2, 4);
const img = ctx.createImageData(TILE, TILE);
const data = img.data;
for (let y = 0; y < TILE; y++) {
const v = y / TILE;
for (let x = 0; x < TILE; x++) {
const u = x / TILE;
const dx = nx1(u, v) - 0.5 + (nx2(u, v) - 0.5) * 0.5;
const dy = ny1(u, v) - 0.5 + (ny2(u, v) - 0.5) * 0.5;
const i = (y * TILE + x) * 4;
data[i] = Math.max(0, Math.min(255, 128 + dx * FLOW_AMP));
data[i + 1] = Math.max(0, Math.min(255, 128 + dy * FLOW_AMP));
data[i + 2] = Math.max(0, Math.min(255, 128 + (dx + dy) * FLOW_AMP * 0.43));
data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas;
}
let FLOW_TILE: HTMLCanvasElement | null | undefined;
function flowTile() {
if (FLOW_TILE === undefined) FLOW_TILE = buildFlowTile();
return FLOW_TILE;
}
function buildFlowMap(w: number, h: number): string {
const tile = flowTile();
if (!tile) return "";
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w));
canvas.height = Math.max(1, Math.round(h));
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const pattern = ctx.createPattern(tile, "repeat");
if (!pattern) return "";
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL();
}
function buildDisturbance(heading: number): string {
const canvas = document.createElement("canvas");
canvas.width = SPRITE_RES;
canvas.height = SPRITE_RES;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const img = ctx.createImageData(SPRITE_RES, SPRITE_RES);
const data = img.data;
const half = SPRITE_RES / 2;
const directional = heading >= 0;
const ch = Math.cos(heading);
const sh = Math.sin(heading);
for (let y = 0; y < SPRITE_RES; y++) {
for (let x = 0; x < SPRITE_RES; x++) {
const dx = (x - half) / half;
const dy = (y - half) / half;
const i = (y * SPRITE_RES + x) * 4;
const s = directional ? dx * ch + dy * sh : dx;
const t = directional ? -dx * sh + dy * ch : dy;
const rs = directional ? (s > 0 ? s / NOSE : s / TAIL) : s;
const rt = directional ? t / SIDE : t;
const r = Math.hypot(rs, rt);
const q = (r - PEAK_AT) / PEAK_WIDTH;
let push = 0;
if (q > -1 && q < 1) {
const win = Math.cos(q * Math.PI * 0.5);
push = Math.sin(q * Math.PI) * win * win;
if (directional) {
const m = Math.hypot(s, t);
const c = m > 1e-5 ? s / m : 0;
push *= 0.05 + 0.95 * Math.pow(0.5 - 0.5 * c, 1.35);
}
}
const mag = Math.abs(push);
if (mag < 1e-3) {
data[i] = 128;
data[i + 1] = 128;
data[i + 2] = 128;
data[i + 3] = 0;
continue;
}
const sgn = push > 0 ? 1 : -1;
const len = Math.hypot(dx, dy) || 1;
data[i] = Math.round(128 + (dx / len) * sgn * 127);
data[i + 1] = Math.round(128 + (dy / len) * sgn * 127);
data[i + 2] = Math.round(128 + sgn * 110);
data[i + 3] = Math.round(Math.min(1, mag) * 255);
}
}
ctx.putImageData(img, 0, 0);
return canvas.toDataURL();
}
let SHAPES: { ring: string; wakes: string[] } | null = null;
function shapes() {
if (!SHAPES) {
SHAPES = {
ring: buildDisturbance(-1),
wakes: Array.from({ length: WAKE_ANGLES }, (_, i) =>
buildDisturbance((i / WAKE_ANGLES) * Math.PI * 2),
),
};
}
return SHAPES;
}
function buildTaper(w: number, h: number, hold: number): string {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w));
canvas.height = Math.max(1, Math.round(h));
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const band = Math.max(
1,
Math.min(canvas.width, canvas.height) * 0.5 * Math.min(Math.max(hold, 0), 1),
);
const img = ctx.createImageData(canvas.width, canvas.height);
const data = img.data;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const d = Math.min(x, y, canvas.width - 1 - x, canvas.height - 1 - y);
const t = band <= 1 ? 1 : smoothstep(0, band, d);
const c = Math.round(t * 255);
const i = (y * canvas.width + x) * 4;
data[i] = c;
data[i + 1] = c;
data[i + 2] = c;
data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas.toDataURL();
}
const hexToRgb01 = (hex: string): [number, number, number] => {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(full, 16);
if (Number.isNaN(n) || full.length !== 6) return [1, 1, 1];
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
};
interface Ripple {
x: number;
y: number;
age: number;
alive: boolean;
href: string;
r0: number;
r1: number;
life: number;
gain: number;
}
const Submerge = memo(
({
children,
depth = 0.45,
flow = 1.25,
turbulence = 0.5,
rippleForce = 0.8,
rippleSpread = 1,
settle = 1.6,
edgeHold = 0.35,
waterColor = "#bacffe",
deepColor = "#d9e7f3",
paused = false,
reducedMotion = false,
className,
}: SubmergeProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const flowRef = useRef<SVGFEImageElement>(null);
const dispRef = useRef<SVGFEDisplacementMapElement>(null);
const spriteRefs = useRef<(SVGFEImageElement | null)[]>([]);
const gateRefs = useRef<(SVGFEComponentTransferElement | null)[]>([]);
const weightRefs = useRef<(SVGFEFuncAElement | null)[]>([]);
const rawId = useId();
const filterId = `sg-submerge-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const box = useRef({ w: 0, h: 0 });
const pointer = useRef({
lastX: NaN,
lastY: NaN,
travel: 0,
lastSpawn: 0,
gap: DEFAULT_GAP,
});
const ripples = useRef<Ripple[]>(
Array.from({ length: RIPPLES }, () => ({
x: 0.5, y: 0.5, age: 0, alive: false, href: "",
r0: 0.04, r1: 0.44, life: 1, gain: 0.7,
})),
);
const nextSlot = useRef(0);
const heading = useRef({ x: 1, y: 0 });
const [maps, setMaps] = useState({ flow: "", taper: "", w: 0, h: 0 });
const live = useRef({
depth, flow, turbulence, rippleForce, rippleSpread, settle,
edgeHold, waterColor, deepColor, reducedMotion,
});
live.current = {
depth, flow, turbulence, rippleForce, rippleSpread, settle,
edgeHold, waterColor, deepColor, reducedMotion,
};
const rebuild = useRef<(() => void) | null>(null);
rebuild.current = () => {
const { w, h } = box.current;
if (w < 2 || h < 2) return;
shapes();
setMaps({
flow: buildFlowMap(w + TILE, h + TILE),
taper: buildTaper(w, h, live.current.edgeHold),
w,
h,
});
};
const loop = useAnimationLoop({
target: containerRef,
halted: paused || reducedMotion,
onResize: (m: Metrics) => {
const changed =
Math.abs(m.width - box.current.w) > 1 ||
Math.abs(m.height - box.current.h) > 1;
box.current = { w: m.width, h: m.height };
if (changed) rebuild.current?.();
},
onFrame: ({ dt, elapsed }) => {
const l = live.current;
const { w, h } = box.current;
if (w < 2 || h < 2) return;
const flowEl = flowRef.current;
if (flowEl) {
const t = elapsed * l.flow;
const ox = -(((t * 19) % TILE) + TILE) % TILE;
const oy = -(((t * 13) % TILE) + TILE) % TILE;
flowEl.setAttribute("x", ox.toFixed(2));
flowEl.setAttribute("y", oy.toFixed(2));
}
const long = Math.max(w, h);
for (let i = 0; i < RIPPLES; i++) {
const r = ripples.current[i];
const img = spriteRefs.current[i];
const gate = gateRefs.current[i];
const weight = weightRefs.current[i];
const lifetime = Math.max(l.settle * r.life, 0.05);
if (r.alive) {
r.age += dt;
if (r.age >= lifetime) r.alive = false;
}
if (!r.alive) {
if (weight) weight.setAttribute("slope", "0");
continue;
}
const p = r.age / lifetime;
const radius =
(r.r0 + (r.r1 - r.r0) * ringGrowth(p)) * long * l.rippleSpread;
const strength = Math.min(
MAX_WEIGHT,
Math.min(1, p / 0.12) * (1 - p) * (1 - p) * l.rippleForce * r.gain,
);
const bx = (r.x * w - radius).toFixed(2);
const by = (r.y * h - radius).toFixed(2);
const bs = (radius * 2).toFixed(2);
if (img) {
if (img.getAttribute("href") !== r.href) {
img.setAttribute("href", r.href);
}
img.setAttribute("x", bx);
img.setAttribute("y", by);
img.setAttribute("width", bs);
img.setAttribute("height", bs);
}
if (gate) {
gate.setAttribute("x", bx);
gate.setAttribute("y", by);
gate.setAttribute("width", bs);
gate.setAttribute("height", bs);
}
if (weight) weight.setAttribute("slope", strength.toFixed(4));
}
const disp = dispRef.current;
if (disp) {
const push = l.turbulence * DISPLACE_BASE * (0.55 + l.depth * 0.9);
disp.setAttribute("scale", push.toFixed(2));
}
},
deps: [],
});
useEffect(() => {
rebuild.current?.();
}, [edgeHold]);
useEffect(() => {
loop.paint();
}, [
depth, flow, turbulence, rippleForce, rippleSpread, settle,
waterColor, deepColor, maps, loop,
]);
const spawn = (x: number, y: number, dirX: number, dirY: number) => {
const slot = nextSlot.current;
const r = ripples.current[slot];
const sh = shapes();
if (dirX !== 0 || dirY !== 0) {
const angle = Math.atan2(dirY, dirX);
const bucket =
((Math.round((angle / (Math.PI * 2)) * WAKE_ANGLES) % WAKE_ANGLES) +
WAKE_ANGLES) %
WAKE_ANGLES;
r.href = sh.wakes[bucket];
r.r0 = 0.075;
r.r1 = 0.18;
r.life = Math.max(
WAKE_LIFE_MIN,
Math.min(
WAKE_LIFE_MAX,
((RIPPLES - 1) * pointer.current.gap) / Math.max(live.current.settle, 0.05),
),
);
r.gain = 0.62;
} else {
r.href = sh.ring;
r.r0 = 0.04;
r.r1 = 0.44;
r.life = 1;
r.gain = 0.85;
}
r.x = x;
r.y = y;
r.age = 0;
r.alive = true;
nextSlot.current = (slot + 1) % RIPPLES;
};
const track = (e: React.PointerEvent<HTMLDivElement>) => {
if (live.current.reducedMotion) return;
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const p = pointer.current;
if (Number.isNaN(p.lastX)) {
p.lastX = px;
p.lastY = py;
p.travel = 0;
return;
}
const dx = px - p.lastX;
const dy = py - p.lastY;
p.travel += Math.hypot(dx, dy);
p.lastX = px;
p.lastY = py;
const step = Math.max(10, Math.min(rect.width, rect.height) * SPAWN_FRACTION);
if (p.travel < step) return;
p.travel = 0;
const now = e.timeStamp;
if (p.lastSpawn > 0) {
const dtSpawn = Math.min(0.5, Math.max(0.008, (now - p.lastSpawn) / 1000));
p.gap += (dtSpawn - p.gap) * 0.4;
}
p.lastSpawn = now;
const len = Math.hypot(dx, dy);
if (len > 1e-5) {
const hd = heading.current;
hd.x += (dx / len - hd.x) * 0.35;
hd.y += (dy / len - hd.y) * 0.35;
const hl = Math.hypot(hd.x, hd.y) || 1;
hd.x /= hl;
hd.y /= hl;
}
const hd = heading.current;
spawn(
(px - hd.x * step * 0.8) / rect.width,
(py - hd.y * step * 0.8) / rect.height,
hd.x,
hd.y,
);
loop.start();
};
const press = (e: React.PointerEvent<HTMLDivElement>) => {
if (live.current.reducedMotion) return;
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
spawn(
(e.clientX - rect.left) / rect.width,
(e.clientY - rect.top) / rect.height,
0,
0,
);
loop.start();
};
const release = () => {
pointer.current.lastX = NaN;
pointer.current.lastY = NaN;
pointer.current.lastSpawn = 0;
};
const ready = maps.flow !== "" && maps.taper !== "";
const deep = hexToRgb01(deepColor);
const deepCss = `rgb(${Math.round(deep[0] * 255)} ${Math.round(deep[1] * 255)} ${Math.round(deep[2] * 255)})`;
return (
<div
ref={containerRef}
className={
className ?? "relative isolate touch-none overflow-hidden select-none"
}
onPointerMove={track}
onPointerDown={press}
onPointerLeave={release}
onPointerCancel={release}
>
<svg aria-hidden className="absolute h-0 w-0" focusable="false">
<defs>
<filter
id={filterId}
colorInterpolationFilters="sRGB"
x="0"
y="0"
width="100%"
height="100%"
>
{ready ? (
<>
<feFlood
floodColor="rgb(128,128,128)"
floodOpacity="1"
result="acc0"
/>
{Array.from({ length: RIPPLES }, (_, i) => (
<feImage
key={`spr-${i}`}
ref={(el) => {
spriteRefs.current[i] = el;
}}
x="0"
y="0"
width="0"
height="0"
preserveAspectRatio="none"
result={`spr${i}`}
/>
))}
{Array.from({ length: RIPPLES }, (_, i) => (
<feComponentTransfer
key={`gate-${i}`}
ref={(el) => {
gateRefs.current[i] = el;
}}
in={`spr${i}`}
x="0"
y="0"
width="0"
height="0"
result={`gate${i}`}
>
<feFuncA
ref={(el) => {
weightRefs.current[i] = el;
}}
type="linear"
slope="0"
intercept="0"
/>
</feComponentTransfer>
))}
{Array.from({ length: RIPPLES }, (_, i) => (
<feComposite
key={`mix-${i}`}
in={`gate${i}`}
in2={`acc${i}`}
operator="over"
result={`acc${i + 1}`}
/>
))}
<feColorMatrix
in={`acc${RIPPLES}`}
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.78 0 -1.395"
result="crestMask"
/>
<feFlood
floodColor={waterColor}
floodOpacity="1"
result="crestTint"
/>
<feComposite
in="crestTint"
in2="crestMask"
operator="in"
result="crestRaw"
/>
<feComponentTransfer in="crestRaw" result="crest">
<feFuncA type="linear" slope="0.85" intercept="0" />
</feComponentTransfer>
<feImage
ref={flowRef}
href={maps.flow}
x="0"
y="0"
width={maps.w + TILE}
height={maps.h + TILE}
preserveAspectRatio="none"
result="current"
/>
<feComposite
in={`acc${RIPPLES}`}
in2="current"
operator="arithmetic"
k1="0"
k2="1"
k3="1"
k4="-0.5"
result="mixed"
/>
<feImage
href={maps.taper}
x="0"
y="0"
width={maps.w}
height={maps.h}
preserveAspectRatio="none"
result="taper"
/>
<feComposite
in="mixed"
in2="taper"
operator="arithmetic"
k1="1"
k2="0"
k3="-0.5"
k4="0.5"
result="surface"
/>
<feDisplacementMap
ref={dispRef}
in="SourceGraphic"
in2="surface"
scale="0"
xChannelSelector="R"
yChannelSelector="G"
result="bent"
/>
<feBlend in="crest" in2="bent" mode="screen" />
</>
) : null}
</filter>
</defs>
</svg>
<div
className="relative h-full w-full"
style={{
filter: ready && !reducedMotion ? `url(#${filterId})` : undefined,
}}
>
{children}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: deepCss,
mixBlendMode: "multiply",
opacity: Math.min(0.85, depth * 0.8),
}}
/>
</div>
</div>
);
},
);
Submerge.displayName = "Submerge";
export default Submerge;