Dot swarm
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Sweep across the dotted sphere to push the dots aside; click for a shockwave.
Code
"use client";
import { useEffect, useRef } from "react";
import { fitCanvas, prefersReducedMotion, Surface } from "../ui";
type Dot = { hx: number; hy: number; x: number; y: number; vx: number; vy: number; s: number };
export default function DotSwarm() {
const canvas = useRef<HTMLCanvasElement>(null);
const pointer = useRef({ x: -9999, y: -9999, burst: 0, bx: 0, by: 0 });
useEffect(() => {
const cv = canvas.current;
if (!cv) return;
let ctx: CanvasRenderingContext2D | null = null;
let dots: Dot[] = [];
let W = 0;
let H = 0;
let raf = 0;
const still = prefersReducedMotion();
const build = () => {
const r = cv.getBoundingClientRect();
W = r.width;
H = r.height;
ctx = fitCanvas(cv, W, H);
const R = Math.min(W, H) * 0.36;
const gap = Math.max(6, R / 11);
dots = [];
for (let row = -13; row <= 13; row++) {
for (let col = -14; col <= 14; col++) {
const x = col * gap + (row % 2 ? gap / 2 : 0);
const y = row * gap * 0.87;
const d = Math.hypot(x, y) / R;
if (d > 1) continue;
// sphere shading: dots are larger toward the middle
const s = gap * 0.32 * (0.45 + 0.55 * Math.sqrt(1 - d * d));
dots.push({ hx: W / 2 + x, hy: H / 2 + y, x: W / 2 + x, y: H / 2 + y, vx: 0, vy: 0, s });
}
}
};
build();
const ro = new ResizeObserver(build);
ro.observe(cv);
let t = 0;
const loop = () => {
t += 1 / 60;
const p = pointer.current;
if (ctx) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "#151515";
const rad = Math.min(W, H) * 0.2;
for (const d of dots) {
// a slow breathing so the sphere is never quite still
const bx = d.hx + Math.sin(t * 1.3 + d.hy * 0.05) * 0.8;
const by = d.hy + Math.cos(t * 1.1 + d.hx * 0.05) * 0.8;
d.vx += (bx - d.x) * 0.06;
d.vy += (by - d.y) * 0.06;
const dx = d.x - p.x;
const dy = d.y - p.y;
const dist = Math.hypot(dx, dy) + 0.01;
if (dist < rad) {
const f = (1 - dist / rad) ** 2 * 3.2;
d.vx += (dx / dist) * f;
d.vy += (dy / dist) * f;
}
if (p.burst > 0) {
const ex = d.x - p.bx;
const ey = d.y - p.by;
const ed = Math.hypot(ex, ey) + 0.01;
const f = (p.burst * 14) / (1 + ed * 0.04);
d.vx += (ex / ed) * f;
d.vy += (ey / ed) * f;
}
d.vx *= 0.82;
d.vy *= 0.82;
d.x += d.vx;
d.y += d.vy;
const sp = Math.min(1, Math.hypot(d.vx, d.vy) / 6);
ctx.beginPath();
ctx.arc(d.x, d.y, d.s * (1 - sp * 0.35), 0, Math.PI * 2);
ctx.fill();
}
p.burst = 0;
}
if (!still) raf = requestAnimationFrame(loop);
};
loop();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, []);
const at = (e: React.PointerEvent) => {
const r = canvas.current!.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
return (
<Surface>
<canvas
ref={canvas}
className="absolute inset-0 h-full w-full touch-none"
onPointerMove={(e) => Object.assign(pointer.current, at(e))}
onPointerLeave={() => Object.assign(pointer.current, { x: -9999, y: -9999 })}
onPointerDown={(e) => {
const p = at(e);
Object.assign(pointer.current, { ...p, burst: 1, bx: p.x, by: p.y });
}}
/>
</Surface>
);
}
Prompt
Build "Dot swarm", a cursor animation. What it does: Sweep across the dotted sphere to push the dots aside; click for a shockwave. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).