fluid particles spring repel pointer physics interactive
Fluid Pointer
fluid-pointer.js
/**
* Fluid Pointer — Particles repel on pointer contact and spring back to origin.
* Category: interactive | Animate: true | Interactive: true | Complexity: medium
*/
export default function fluidPointer(ctx, state) {
const COUNT = 80;
const PUSH_RADIUS = 90;
const PUSH_FORCE = 5;
const FRICTION = 0.91;
const RETURN_FORCE = 0.045;
const COLOR = '99, 102, 241'; // indigo-500
let particles = [];
let hasInteracted = false;
let lastDemoTime = 0;
let demoIndex = 0;
const DEMO_CENTERS = [
[0.3, 0.4],
[0.7, 0.6],
[0.5, 0.3],
[0.2, 0.65],
[0.8, 0.35],
];
function init(w, h) {
particles = [];
for (let i = 0; i < COUNT; i++) {
const x = (Math.random() * 0.8 + 0.1) * w;
const y = (Math.random() * 0.8 + 0.1) * h;
particles.push({ x, y, ox: x, oy: y, vx: 0, vy: 0 });
}
}
return {
resize({ width, height }) {
init(width, height);
},
frame({ width, height, pointer, reducedMotion, time }) {
if (!particles.length) init(width, height);
if (pointer.inside) hasInteracted = true;
// Auto-demo ripple when user hasn't touched the canvas
if (!hasInteracted && !reducedMotion && time - lastDemoTime > 1.2) {
const [cx, cy] = DEMO_CENTERS[demoIndex % DEMO_CENTERS.length];
const px = cx * width;
const py = cy * height;
for (const p of particles) {
const dx = p.x - px;
const dy = p.y - py;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < PUSH_RADIUS && dist > 0) {
const force = (1 - dist / PUSH_RADIUS) * PUSH_FORCE;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
}
demoIndex++;
lastDemoTime = time;
}
for (const p of particles) {
// Spring return
p.vx += (p.ox - p.x) * RETURN_FORCE;
p.vy += (p.oy - p.y) * RETURN_FORCE;
// Pointer repulsion
if (pointer.inside && !reducedMotion) {
const dx = p.x - pointer.x;
const dy = p.y - pointer.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < PUSH_RADIUS && dist > 0) {
const force = (1 - dist / PUSH_RADIUS) * PUSH_FORCE;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
}
if (!reducedMotion) {
p.vx *= FRICTION;
p.vy *= FRICTION;
p.x += p.vx;
p.y += p.vy;
}
const displacement = Math.sqrt((p.x - p.ox) ** 2 + (p.y - p.oy) ** 2);
const alpha = 0.12 + 0.55 * Math.min(displacement / 40, 1);
const radius = 1.8 + Math.min(displacement * 0.07, 3.5);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${COLOR}, ${alpha})`;
ctx.fill();
}
},
destroy() {
particles = [];
},
};
}
embed.html
<canvas id="canvas-effect" style="width:100%;height:400px;display:block" aria-hidden="true"></canvas>
<script>
/**
* mountCanvas — Minimal runtime for Canvas effects.
*
* Handles HiDPI scaling, ResizeObserver, RAF loop, pointer tracking,
* reduced-motion, and cleanup. The effect only implements draw logic.
*
* @param {HTMLCanvasElement} canvas
* @param {(ctx: CanvasRenderingContext2D, state: object) => object} createEffect
* @param {object} [options]
* @returns {() => void} destroy function
*/
function mountCanvas(canvas, createEffect, options = {}) {
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) throw new Error('2D context not available');
const opts = {
animate: true,
clear: true,
maxDpr: 2,
pauseWhenHidden: true,
respectReducedMotion: true,
...options,
};
const reducedMotion =
opts.respectReducedMotion && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const state = {
canvas,
ctx,
width: 0,
height: 0,
pixelWidth: 0,
pixelHeight: 0,
dpr: 1,
time: 0,
delta: 0,
frame: 0,
playing: false,
reducedMotion,
pointer: { x: 0, y: 0, inside: false, down: false },
};
let rafId = 0;
let lastTime = 0;
let disposed = false;
function measure() {
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, rect.width);
const h = Math.max(1, rect.height);
const dpr = Math.min(window.devicePixelRatio || 1, opts.maxDpr);
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
state.width = w;
state.height = h;
state.pixelWidth = pw;
state.pixelHeight = ph;
state.dpr = dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clear() {
ctx.clearRect(0, 0, state.width, state.height);
}
const effect = createEffect(ctx, state) || {};
function onResize() {
measure();
effect.resize?.(state);
if (!opts.animate || reducedMotion) {
if (opts.clear) clear();
effect.frame?.(state);
}
}
function loop(now) {
if (disposed) return;
if (opts.pauseWhenHidden && document.hidden) {
rafId = requestAnimationFrame(loop);
return;
}
state.delta = lastTime ? (now - lastTime) / 1000 : 0;
state.time = now / 1000;
state.frame += 1;
lastTime = now;
if (opts.clear) clear();
effect.frame?.(state);
rafId = requestAnimationFrame(loop);
}
function onPointerMove(e) {
const rect = canvas.getBoundingClientRect();
state.pointer.x = e.clientX - rect.left;
state.pointer.y = e.clientY - rect.top;
if (!opts.animate || reducedMotion) {
if (opts.clear) clear();
effect.frame?.(state);
}
}
function onPointerEnter() {
state.pointer.inside = true;
}
function onPointerLeave() {
state.pointer.inside = false;
state.pointer.down = false;
}
function onPointerDown() {
state.pointer.down = true;
}
function onPointerUp() {
state.pointer.down = false;
}
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerenter', onPointerEnter);
canvas.addEventListener('pointerleave', onPointerLeave);
canvas.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerUp);
measure();
if (opts.animate && !reducedMotion) {
state.playing = true;
rafId = requestAnimationFrame(loop);
} else {
effect.frame?.(state);
}
return function destroy() {
disposed = true;
state.playing = false;
cancelAnimationFrame(rafId);
ro.disconnect();
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerenter', onPointerEnter);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerUp);
effect.destroy?.(state);
};
}
/**
* Fluid Pointer — Particles repel on pointer contact and spring back to origin.
* Category: interactive | Animate: true | Interactive: true | Complexity: medium
*/
const __effect = function fluidPointer(ctx, state) {
const COUNT = 80;
const PUSH_RADIUS = 90;
const PUSH_FORCE = 5;
const FRICTION = 0.91;
const RETURN_FORCE = 0.045;
const COLOR = '99, 102, 241'; // indigo-500
let particles = [];
let hasInteracted = false;
let lastDemoTime = 0;
let demoIndex = 0;
const DEMO_CENTERS = [
[0.3, 0.4],
[0.7, 0.6],
[0.5, 0.3],
[0.2, 0.65],
[0.8, 0.35],
];
function init(w, h) {
particles = [];
for (let i = 0; i < COUNT; i++) {
const x = (Math.random() * 0.8 + 0.1) * w;
const y = (Math.random() * 0.8 + 0.1) * h;
particles.push({ x, y, ox: x, oy: y, vx: 0, vy: 0 });
}
}
return {
resize({ width, height }) {
init(width, height);
},
frame({ width, height, pointer, reducedMotion, time }) {
if (!particles.length) init(width, height);
if (pointer.inside) hasInteracted = true;
// Auto-demo ripple when user hasn't touched the canvas
if (!hasInteracted && !reducedMotion && time - lastDemoTime > 1.2) {
const [cx, cy] = DEMO_CENTERS[demoIndex % DEMO_CENTERS.length];
const px = cx * width;
const py = cy * height;
for (const p of particles) {
const dx = p.x - px;
const dy = p.y - py;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < PUSH_RADIUS && dist > 0) {
const force = (1 - dist / PUSH_RADIUS) * PUSH_FORCE;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
}
demoIndex++;
lastDemoTime = time;
}
for (const p of particles) {
// Spring return
p.vx += (p.ox - p.x) * RETURN_FORCE;
p.vy += (p.oy - p.y) * RETURN_FORCE;
// Pointer repulsion
if (pointer.inside && !reducedMotion) {
const dx = p.x - pointer.x;
const dy = p.y - pointer.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < PUSH_RADIUS && dist > 0) {
const force = (1 - dist / PUSH_RADIUS) * PUSH_FORCE;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
}
if (!reducedMotion) {
p.vx *= FRICTION;
p.vy *= FRICTION;
p.x += p.vx;
p.y += p.vy;
}
const displacement = Math.sqrt((p.x - p.ox) ** 2 + (p.y - p.oy) ** 2);
const alpha = 0.12 + 0.55 * Math.min(displacement / 40, 1);
const radius = 1.8 + Math.min(displacement * 0.07, 3.5);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${COLOR}, ${alpha})`;
ctx.fill();
}
},
destroy() {
particles = [];
},
};
}
const canvas = document.getElementById('canvas-effect');
mountCanvas(canvas, __effect, { animate: true, clear: true });
</script> Details
Animated Interactive Reduced Motion aria-hidden
fluidparticlesspringrepelpointerphysicsinteractive
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| COUNT | number | 80 | Number of particles |
| PUSH_RADIUS | number | 90 | Pointer influence radius in CSS pixels |
| PUSH_FORCE | number | 5 | Repulsion force magnitude |
| FRICTION | number | 0.91 | Velocity damping per frame (0 = instant stop, 1 = no damping) |
| RETURN_FORCE | number | 0.045 | Spring constant — strength of return pull toward origin |
80 particles orbit their rest positions under spring physics. Moving the cursor over the canvas applies a radial repulsion force — particles scatter, then return with oscillation. Auto-demo mode fires a wave every 1.2 seconds until the user interacts.
prefers-reduced-motion disables all particle velocity and renders them at rest positions only.