Fazekit

Code

"use client";
import * as React from "react";
import { cn } from "@/lib/utils";

export interface KineticDotGridProps extends React.ComponentProps<"div"> {
  /** Distance between dots in px. */
  gap?: number;
  /** Radius of cursor influence in px. */
  radius?: number;
  /** How far dots are pushed (0–1). */
  strength?: number;
  /** Dot color; defaults to the current text color. */
  color?: string;
  /** Glow color under the cursor. */
  glow?: string;
}

type Dot = { ox: number; oy: number; x: number; y: number; vx: number; vy: number };

export function KineticDotGrid({
  gap = 28,
  radius = 140,
  strength = 0.6,
  color,
  glow = "rgba(129, 140, 248, 0.35)",
  className,
  children,
  ...props
}: KineticDotGridProps) {
  const wrapRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  React.useEffect(() => {
    const wrap = wrapRef.current!;
    const canvas = canvasRef.current!;
    const ctx = canvas.getContext("2d")!;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let dots: Dot[] = [];
    let w = 0, h = 0, raf = 0;
    const mouse = { x: -9999, y: -9999, active: false };

    function layout() {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = wrap.clientWidth;
      h = wrap.clientHeight;
      canvas.width = w * dpr;
      canvas.height = h * dpr;
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      dots = [];
      const offX = (w % gap) / 2, offY = (h % gap) / 2;
      for (let x = offX; x <= w; x += gap)
        for (let y = offY; y <= h; y += gap) dots.push({ ox: x, oy: y, x, y, vx: 0, vy: 0 });
    }

    function frame() {
      const fill = color ?? getComputedStyle(wrap).color;
      ctx.clearRect(0, 0, w, h);
      if (mouse.active) {
        const g = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, radius * 1.4);
        g.addColorStop(0, glow);
        g.addColorStop(1, "transparent");
        ctx.fillStyle = g;
        ctx.fillRect(0, 0, w, h);
      }
      ctx.fillStyle = fill;
      for (const d of dots) {
        const dx = d.x - mouse.x, dy = d.y - mouse.y;
        const dist = Math.hypot(dx, dy);
        if (mouse.active && dist < radius && dist > 0.01) {
          const f = (1 - dist / radius) * strength * 6;
          d.vx += (dx / dist) * f;
          d.vy += (dy / dist) * f;
        }
        // spring back to origin + damping
        d.vx += (d.ox - d.x) * 0.08;
        d.vy += (d.oy - d.y) * 0.08;
        d.vx *= 0.78;
        d.vy *= 0.78;
        d.x += d.vx;
        d.y += d.vy;
        const moved = Math.min(Math.hypot(d.x - d.ox, d.y - d.oy) / 18, 1);
        ctx.globalAlpha = 0.28 + moved * 0.72;
        ctx.beginPath();
        ctx.arc(d.x, d.y, 1.2 + moved * 1.3, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;
      raf = requestAnimationFrame(frame);
    }

    const onMove = (e: PointerEvent) => {
      const r = wrap.getBoundingClientRect();
      mouse.x = e.clientX - r.left;
      mouse.y = e.clientY - r.top;
      mouse.active = !reduce;
    };
    const onLeave = () => (mouse.active = false);

    layout();
    frame();
    const ro = new ResizeObserver(layout);
    ro.observe(wrap);
    wrap.addEventListener("pointermove", onMove);
    wrap.addEventListener("pointerleave", onLeave);
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerleave", onLeave);
    };
  }, [gap, radius, strength, color, glow]);

  return (
    <div ref={wrapRef} className={cn("relative overflow-hidden text-neutral-400 dark:text-neutral-600", className)} {...props}>
      <canvas ref={canvasRef} aria-hidden className="absolute inset-0" />
      <div className="relative">{children}</div>
    </div>
  );
}

More in Backgrounds & Effects

View all →