Skip to content

Instantly share code, notes, and snippets.

@chriscooning
Created August 30, 2025 00:54
Show Gist options
  • Save chriscooning/320a9f8e680bcdda28f8bbe8be964418 to your computer and use it in GitHub Desktop.
Save chriscooning/320a9f8e680bcdda28f8bbe8be964418 to your computer and use it in GitHub Desktop.
Fluid dynamics particle effects for website bg
import React, { useEffect, useRef, useState } from "react";
// Fluid dots — slower, subtler, and with pseudo‑Z displacement
// Includes a utility window with sliders to tune physics in real time.
// Now: springK can fluctuate for subtle undulation, and ripple propagation pushes nearby dots.
// Click to pause. Press +/- to change density. Hold Alt for stronger pull.
export default function RipplingDots() {
const canvasRef = useRef(null);
const pausedRef = useRef(false);
const densityRef = useRef(0);
const rafRef = useRef(0);
// Live‑tunable physics settings
const defaultSettings = {
springK: 1.0e-2,
damping: 0.94,
fieldStrength: 6,
advectGain: 0.006,
zSpring: 0.012,
zDamping: 0.96,
zCoupling: 0.45,
zDiffusion: 0.12,
supportRScale: 0.45,
supportRMin: 3,
springJitter: 0.2,
jitterSpeed: 0.15,
rippleCoupling: 0.15
};
const [settings, setSettings] = useState(defaultSettings);
const settingsRef = useRef(defaultSettings);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
let width = 0, height = 0, dpr = 1;
const resize = () => {
dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
width = canvas.clientWidth;
height = canvas.clientHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
rebuild();
};
const ro = new ResizeObserver(resize);
ro.observe(canvas);
const pointer = { x: -9999, y: -9999, vx: 0, vy: 0, t: performance.now(), alt: false };
const velSmooth = 0.12;
const onMove = (e) => {
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const x = clientX - rect.left;
const y = clientY - rect.top;
const now = performance.now();
const dt = Math.max(0.016, (now - pointer.t) / 1000);
const vx = (x - pointer.x) / dt;
const vy = (y - pointer.y) / dt;
if (isFinite(vx) && isFinite(vy)) {
pointer.vx = pointer.vx * (1 - velSmooth) + vx * velSmooth;
pointer.vy = pointer.vy * (1 - velSmooth) + vy * velSmooth;
}
pointer.x = x; pointer.y = y; pointer.t = now;
pointer.alt = e.altKey || (e.touches && e.touches.length > 1);
};
const onLeave = () => { pointer.x = -9999; pointer.y = -9999; };
const onKey = (e) => {
if (e.key === "+") densityRef.current = Math.max(6, (densityRef.current || Math.round(Math.min(width, height) / 45)) - 2);
if (e.key === "-") densityRef.current = Math.min(42, (densityRef.current || Math.round(Math.min(width, height) / 45)) + 2);
};
canvas.addEventListener("mousemove", onMove, { passive: true });
canvas.addEventListener("touchmove", onMove, { passive: true });
canvas.addEventListener("mouseleave", onLeave);
canvas.addEventListener("touchend", onLeave);
window.addEventListener("keydown", onKey);
const rand = (x, y) => { const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123; return s - Math.floor(s); };
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (t) => t * t * (3 - 2 * t);
const noise2 = (x, y) => {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a = rand(xi, yi), b = rand(xi + 1, yi), c = rand(xi, yi + 1), d = rand(xi + 1, yi + 1);
const ux = smooth(xf), uy = smooth(yf);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
};
let spacing = 16, cols = 0, rows = 0;
let homeX = [], homeY = [], posX = [], posY = [], velX = [], velY = [], posZ = [], velZ = [], phase = [];
function rebuild() {
const auto = Math.round(Math.max(8, Math.min(28, Math.min(width, height) / 45)));
spacing = densityRef.current > 0 ? densityRef.current : auto;
cols = Math.ceil(width / spacing) + 2;
rows = Math.ceil(height / spacing) + 2;
const N = cols * rows;
homeX = new Float32Array(N); homeY = new Float32Array(N);
posX = new Float32Array(N); posY = new Float32Array(N);
velX = new Float32Array(N); velY = new Float32Array(N);
posZ = new Float32Array(N); velZ = new Float32Array(N);
phase = new Float32Array(N);
let k = 0;
for (let j = -1; j < rows - 1; j++) {
for (let i = -1; i < cols - 1; i++, k++) {
const x = i * spacing, y = j * spacing;
homeX[k] = x; homeY[k] = y;
posX[k] = x; posY[k] = y; posZ[k] = 0;
velX[k] = 0; velY[k] = 0; velZ[k] = 0;
phase[k] = Math.random() * Math.PI * 2;
}
}
}
const kernel = (r, h) => {
const q = r / h;
if (q >= 1) return 0;
if (q < 0.5) {
return (6.0 * ((2 / 3) - 2 * q * q + 2 * q * q * q));
} else {
return (6.0 * ((2 / 3) * Math.pow(1 - q, 3)));
}
};
let t0 = performance.now();
const draw = (now) => {
if (pausedRef.current) { rafRef.current = requestAnimationFrame(draw); return; }
const t = (now - t0) * 0.001;
const h = Math.max(settingsRef.current.supportRMin, Math.min(width, height) * settingsRef.current.supportRScale);
const N = posX.length;
for (let k = 0; k < N; k++) {
const hx = homeX[k], hy = homeY[k];
// springK fluctuates slightly with noise for subtle undulation
const nJ = (noise2(hx * 0.004 + t * settingsRef.current.jitterSpeed, hy * 0.004 - t * settingsRef.current.jitterSpeed) - 0.5) * 2.0;
const kSpring = settingsRef.current.springK * (1.0 + settingsRef.current.springJitter * nJ);
let ax = (hx - posX[k]) * kSpring;
let ay = (hy - posY[k]) * kSpring;
const dx = posX[k] - pointer.x;
const dy = posY[k] - pointer.y;
const r = Math.hypot(dx, dy) + 1e-4;
const w = kernel(r, h);
if (w > 0) {
const invR = 1 / r;
const bias = pointer.alt ? -0.9 : -0.15;
const f = settingsRef.current.fieldStrength * w * (1 + bias);
ax += f * dx * invR;
ay += f * dy * invR;
ax += pointer.vx * (settingsRef.current.advectGain * w);
ay += pointer.vy * (settingsRef.current.advectGain * w);
velZ[k] += settingsRef.current.zCoupling * w * (pointer.alt ? -1 : 1);
}
const ns = 0.01;
const n1 = noise2(hx * ns + t * 0.06, hy * ns + t * 0.05) - 0.5;
const n2 = noise2(hx * ns * 0.8 - t * 0.05, hy * ns * 1.1 - t * 0.07) - 0.5;
ax += n1 * 0.18; ay += n2 * 0.18;
velX[k] = (velX[k] + ax) * settingsRef.current.damping;
velY[k] = (velY[k] + ay) * settingsRef.current.damping;
posX[k] += velX[k];
posY[k] += velY[k];
}
if (cols > 2 && rows > 2) {
for (let j = 1; j < rows - 1; j++) {
for (let i = 1; i < cols - 1; i++) {
const k = j * cols + i;
const km = k - 1, kp = k + 1, kj = k - cols, kjp = k + cols;
const center = posZ[k];
const lap = (posZ[km] + posZ[kp] + posZ[kj] + posZ[kjp] - 4 * center);
velZ[k] += settingsRef.current.zDiffusion * lap;
velX[k] += settingsRef.current.rippleCoupling * lap;
velY[k] += settingsRef.current.rippleCoupling * lap;
}
}
}
for (let k = 0; k < N; k++) {
velZ[k] += -settingsRef.current.zSpring * posZ[k];
velZ[k] *= settingsRef.current.zDamping;
posZ[k] += velZ[k];
}
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, width, height);
const cx = width * 0.5, cy = height * 0.5; const maxR = Math.hypot(cx, cy);
for (let k = 0; k < N; k++) {
const z = posZ[k];
const parallax = 0.15 * z;
const x = posX[k] + parallax;
const y = posY[k] - parallax * 0.5;
phase[k] += 0.01 + Math.hypot(velX[k], velY[k]) * 0.008;
const breathe = 0.40 + 0.30 * (0.5 + 0.5 * Math.sin(phase[k]));
const vignette = 1.0 - (Math.hypot(x - cx, y - cy) / maxR) * 0.55;
const alpha = Math.max(0, Math.min(1, breathe + z * 0.35)) * vignette;
const rDot = Math.max(0.6, 1.5 + z * 1.8 + Math.hypot(velX[k], velY[k]) * 0.01);
ctx.beginPath();
ctx.arc(x, y, rDot, 0, Math.PI * 2);
ctx.fillStyle = `rgba(235,235,235,${alpha})`;
ctx.fill();
}
rafRef.current = requestAnimationFrame(draw);
};
const onClick = () => (pausedRef.current = !pausedRef.current);
canvas.addEventListener("click", onClick);
rafRef.current = requestAnimationFrame(draw);
return () => {
canvas.removeEventListener("mousemove", onMove);
canvas.removeEventListener("touchmove", onMove);
canvas.removeEventListener("mouseleave", onLeave);
canvas.removeEventListener("touchend", onLeave);
window.removeEventListener("keydown", onKey);
canvas.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
ro.disconnect();
};
}, []);
return (
<div className="w-screen h-screen bg-black overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full block cursor-pointer"
aria-label="Animated rippling dots background"
title="Click to pause • +/- to change density • Alt: stronger pull"
/>
<Controls
settings={settings}
onChange={(patch) => {
setSettings((s) => ({ ...s, ...patch }));
settingsRef.current = { ...settingsRef.current, ...patch };
}}
onReset={() => {
setSettings(defaultSettings);
settingsRef.current = { ...defaultSettings };
}}
/>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs text-gray-400 tracking-wide select-none">
Click to pause • Press +/− to change density • Hold Alt for stronger pull
</div>
</div>
);
}
function Controls({ settings, onChange, onReset }) {
const [open, setOpen] = useState(true);
const Row = ({ label, value, min, max, step, keyName }) => (
<label className="flex items-center gap-2 text-xs">
<span className="w-28 text-gray-300">{label}</span>
<input
type="range"
className="flex-1"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange({ [keyName]: parseFloat(e.target.value) })}
/>
<span className="w-14 text-right tabular-nums text-gray-400">
{(value < 0.001 && value !== 0) ? value.toExponential(2) : Number.isInteger(value) ? value : value.toFixed(4)}
</span>
</label>
);
return (
<div className="absolute right-3 top-3 text-white select-none">
<button
className="px-2 py-1 text-xs rounded bg-white/10 hover:bg-white/20 backdrop-blur shadow"
onClick={() => setOpen((v) => !v)}
aria-label="Toggle controls"
>
{open ? "Hide" : "Show"} Controls
</button>
{open && (
<div className="mt-2 w-80 max-w-[85vw] p-3 rounded-xl bg-black/60 backdrop-blur shadow-lg border border-white/10 space-y-2">
<Row label="springK" value={settings.springK} min={0.00001} max={0.050} step={0.00001} keyName="springK" />
<Row label="damping" value={settings.damping} min={0.850} max={0.999} step={0.001} keyName="damping" />
<Row label="fieldStrength" value={settings.fieldStrength} min={0} max={20} step={0.1} keyName="fieldStrength" />
<Row label="advectGain" value={settings.advectGain} min={0} max={0.030} step={0.001} keyName="advectGain" />
<Row label="zSpring" value={settings.zSpring} min={0.001} max={0.050} step={0.001} keyName="zSpring" />
<Row label="zDamping" value={settings.zDamping} min={0.900} max={0.999} step={0.001} keyName="zDamping" />
<Row label="zCoupling" value={settings.zCoupling} min={0} max={1.000} step={0.01} keyName="zCoupling" />
<Row label="zDiffusion" value={settings.zDiffusion} min={0} max={0.400} step={0.001} keyName="zDiffusion" />
<Row label="support scale" value={settings.supportRScale} min={0.05} max={0.80} step={0.01} keyName="supportRScale" />
<Row label="support min" value={settings.supportRMin} min={2} max={400} step={1} keyName="supportRMin" />
<Row label="springJitter" value={settings.springJitter} min={0} max={1} step={0.01} keyName="springJitter" />
<Row label="jitterSpeed" value={settings.jitterSpeed} min={0} max={1} step={0.01} keyName="jitterSpeed" />
<Row label="rippleCoupling" value={settings.rippleCoupling} min={0} max={1} step={0.01} keyName="rippleCoupling" />
<div className="pt-1 flex items-center justify-between">
<button className="px-2 py-1 text-xs rounded bg-white/10 hover:bg-white/20" onClick={onReset}>Reset</button>
<span className="text-[10px] text-gray-400">Alt = stronger pull</span>
</div>
</div>
)}
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment