"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>
);
}