shitlab · 01
Water Tank
Click to pour water from the cursor, push it around with the mouse. Particle physics on a 2d canvas, goo filter for the liquid look. One file, zero dependencies, copy it and mess with it.
clique pour verser · bouge pour pousser
How to rebuild it
components/WaterPlayground.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
type Particle = { x: number; y: number; vx: number; vy: number };
const RADIUS = 6;
const MIN_DIST = RADIUS * 1.8;
export default function WaterPlayground() {
const t = useTranslations("minimal");
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const [interacted, setInteracted] = useState(false);
const [canReset, setCanReset] = useState(false);
const canResetRef = useRef(false);
const resetRef = useRef<() => void>(() => {});
useEffect(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let w = 0;
let h = 0;
let raf = 0;
let running = false;
let maxParts = 500;
const parts: Particle[] = [];
const mouse = { x: 0, y: 0, px: 0, py: 0, in: false };
let pouring = false;
let liquid = "#422419";
const readColors = () => {
liquid = document.documentElement.classList.contains("dark")
? "#4FB6F0"
: "#38A2E8";
};
readColors();
resetRef.current = () => {
parts.length = 0;
canResetRef.current = false;
setCanReset(false);
};
const themeObs = new MutationObserver(readColors);
themeObs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const r = wrap.getBoundingClientRect();
w = r.width;
h = r.height;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// enough particles to fill the whole container when poured
maxParts = Math.min(
2600,
Math.ceil(((w * h) / (MIN_DIST * MIN_DIST)) * 1.15)
);
};
resize();
const resObs = new ResizeObserver(resize);
resObs.observe(wrap);
const step = () => {
const mvx = mouse.x - mouse.px;
const mvy = mouse.y - mouse.py;
mouse.px = mouse.x;
mouse.py = mouse.y;
if (pouring) {
// tap stream: a thin jet falling from just below the cursor
for (let k = 0; k < 3; k++) {
if (parts.length >= maxParts) break;
parts.push({
x: mouse.x + (Math.random() - 0.5) * 5,
y: mouse.y + 10 + Math.random() * 6,
vx: (Math.random() - 0.5) * 0.5,
vy: 1.8 + Math.random() * 1.4,
});
}
}
// forces
for (const p of parts) {
p.vy += 0.22;
if (mouse.in) {
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const R = 48;
const d2 = dx * dx + dy * dy;
if (d2 < R * R) {
const d = Math.sqrt(d2) || 1;
const f = 1 - d / R;
p.vx += (dx / d) * f * 1.5 + mvx * 0.28 * f;
p.vy += (dy / d) * f * 1.5 + mvy * 0.28 * f;
}
}
p.vx *= 0.96;
p.vy *= 0.985;
p.x += p.vx;
p.y += p.vy;
}
// particle separation via spatial hash
const cell = RADIUS * 2;
const grid = new Map<string, number[]>();
for (let i = 0; i < parts.length; i++) {
const k = `${(parts[i].x / cell) | 0},${(parts[i].y / cell) | 0}`;
const a = grid.get(k);
if (a) a.push(i);
else grid.set(k, [i]);
}
const minDist = MIN_DIST;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
const cx = (p.x / cell) | 0;
const cy = (p.y / cell) | 0;
for (let ox = -1; ox <= 1; ox++) {
for (let oy = -1; oy <= 1; oy++) {
const bucket = grid.get(`${cx + ox},${cy + oy}`);
if (!bucket) continue;
for (const j of bucket) {
if (j <= i) continue;
const q = parts[j];
const dx = q.x - p.x;
const dy = q.y - p.y;
const d2 = dx * dx + dy * dy;
if (d2 > 0 && d2 < minDist * minDist) {
const d = Math.sqrt(d2);
const push = ((minDist - d) / d) * 0.2;
const px = dx * push;
const py = dy * push;
p.x -= px;
p.y -= py;
q.x += px;
q.y += py;
}
}
}
}
}
// walls + rest state so still water actually stays still
for (const p of parts) {
if (p.x < RADIUS) {
p.x = RADIUS;
p.vx *= -0.3;
} else if (p.x > w - RADIUS) {
p.x = w - RADIUS;
p.vx *= -0.3;
}
if (p.y > h - RADIUS) {
p.y = h - RADIUS;
p.vy = Math.abs(p.vy) < 0.6 ? 0 : p.vy * -0.2;
p.vx *= 0.9;
} else if (p.y < RADIUS) {
p.y = RADIUS;
p.vy = 0;
}
if (p.vx * p.vx + p.vy * p.vy < 0.012) {
p.vx = 0;
p.vy = 0;
}
}
// reset affordance once there is a real amount of water
if (!canResetRef.current && parts.length > maxParts * 0.12) {
canResetRef.current = true;
setCanReset(true);
}
// render
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = liquid;
ctx.beginPath();
for (const p of parts) {
ctx.moveTo(p.x + RADIUS + 1, p.y);
ctx.arc(p.x, p.y, RADIUS + 1, 0, Math.PI * 2);
}
ctx.fill();
raf = requestAnimationFrame(step);
};
const start = () => {
if (!running) {
running = true;
raf = requestAnimationFrame(step);
}
};
const stop = () => {
running = false;
cancelAnimationFrame(raf);
};
const io = new IntersectionObserver(
([e]) => (e.isIntersecting ? start() : stop()),
{ threshold: 0 }
);
io.observe(wrap);
const toLocal = (e: PointerEvent) => {
const r = wrap.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
};
const onDown = (e: PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
toLocal(e);
mouse.px = mouse.x;
mouse.py = mouse.y;
mouse.in = true;
pouring = true;
wrap.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
toLocal(e);
mouse.in = true;
};
const onUp = () => {
pouring = false;
};
const onLeave = () => {
pouring = false;
mouse.in = false;
};
wrap.addEventListener("pointerdown", onDown);
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerup", onUp);
wrap.addEventListener("pointerleave", onLeave);
return () => {
stop();
io.disconnect();
resObs.disconnect();
themeObs.disconnect();
wrap.removeEventListener("pointerdown", onDown);
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerup", onUp);
wrap.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<div
ref={wrapRef}
onPointerDown={() => setInteracted(true)}
className="relative h-[280px] touch-none select-none overflow-hidden rounded-2xl border border-border bg-card"
>
<svg aria-hidden className="absolute h-0 w-0">
<defs>
<filter id="goo-water">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9"
/>
</filter>
</defs>
</svg>
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full"
style={{ filter: "url(#goo-water)" }}
/>
<span
className={`pointer-events-none absolute inset-x-0 bottom-3 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground transition-opacity duration-500 ${
interacted ? "opacity-0" : "opacity-100"
}`}
>
{t("playgroundHint")}
</span>
<button
type="button"
aria-label="Reset water"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
resetRef.current();
}}
className={`absolute right-2.5 top-2.5 flex h-7 w-7 items-center justify-center rounded-full border border-border bg-background/80 text-sm leading-none text-muted-foreground backdrop-blur transition-all duration-300 hover:text-foreground ${
canReset ? "scale-100 opacity-100" : "pointer-events-none scale-75 opacity-0"
}`}
>
×
</button>
</div>
);
}