Baktiyar Ablaikhan

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).