Created
August 30, 2025 00:54
-
-
Save chriscooning/320a9f8e680bcdda28f8bbe8be964418 to your computer and use it in GitHub Desktop.
Fluid dynamics particle effects for website bg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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