Spider Grid interaction
An interactive mouse-following spider effect that dynamically attaches animated legs to nearby grid dots in real time inspired by Dante.
Loading demo...
"use client";
import { useEffect, useRef, useCallback } from "react";
import { motion, useMotionValue, useSpring } from "framer-motion";
// ---- Config ----
const GRID_GAP = 40;
const DOT_RADIUS = 2;
const BODY_SIZE = 20;
const LEG_COUNT = 14;
const ATTACH_RADIUS = 200;
const INFLUENCE_RADIUS = 200;
const DOT_COLOR = "#f97316";
interface Dot {
x: number;
y: number;
}
interface Leg extends Dot {
index: number;
dist: number;
}
const SpiderGrid = () => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const dotsRef = useRef<Dot[]>([]);
const mouseRef = useRef({ x: -9999, y: -9999 });
const legsRef = useRef<Leg[]>([]);
const rafIdRef = useRef<number>(0);
const svgRef = useRef<SVGSVGElement>(null);
// Motion values (for smooth spider body + legs animation)
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const springX = useSpring(mouseX, { stiffness: 300, damping: 25 });
const springY = useSpring(mouseY, { stiffness: 300, damping: 25 });
// ---- Generate Grid ----
const generateDots = useCallback(() => {
if (!containerRef.current || !canvasRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
// Size canvas to container (account for high-DPI screens)
const dpr = window.devicePixelRatio || 1;
canvasRef.current.width = rect.width * dpr;
canvasRef.current.height = rect.height * dpr;
canvasRef.current.style.width = `${rect.width}px`;
canvasRef.current.style.height = `${rect.height}px`;
const ctx = canvasRef.current.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
const cols = Math.ceil(rect.width / GRID_GAP);
const rows = Math.ceil(rect.height / GRID_GAP);
const newDots: Dot[] = [];
for (let y = 0; y <= rows; y++) {
for (let x = 0; x <= cols; x++) {
newDots.push({ x: x * GRID_GAP, y: y * GRID_GAP });
}
}
dotsRef.current = newDots;
}, []);
// ---- Compute legs from mouse + dots ----
const computeLegs = useCallback(() => {
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
const dots = dotsRef.current;
const candidates: Leg[] = [];
for (let i = 0; i < dots.length; i++) {
const dx = dots[i].x - mx;
const dy = dots[i].y - my;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < ATTACH_RADIUS) {
candidates.push({ x: dots[i].x, y: dots[i].y, index: i, dist });
}
}
candidates.sort((a, b) => a.dist - b.dist);
legsRef.current = candidates.slice(0, LEG_COUNT);
}, []);
// ---- Draw dots on canvas ----
const drawDots = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dots = dotsRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
const legSet = new Set(legsRef.current.map((l) => l.index));
// Reset transform to clear the entire backing store in pixel space
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
for (let i = 0; i < dots.length; i++) {
const dx = dots[i].x - mx;
const dy = dots[i].y - my;
const dist = Math.sqrt(dx * dx + dy * dy);
const isLeg = legSet.has(i);
let radius = DOT_RADIUS;
if (isLeg) {
// Leg dots: scale based on distance within ATTACH_RADIUS
// Closer to mouse = bigger, farther = smaller
const t = 1 - dist / ATTACH_RADIUS;
radius = DOT_RADIUS * (1 + (2.4 - 1) * t);
} else if (dist < INFLUENCE_RADIUS) {
// Non-leg dots: subtle scale near mouse
const t = 1 - dist / INFLUENCE_RADIUS;
radius = DOT_RADIUS * (1 + (1.4 - 1) * t);
}
ctx.fillStyle = isLeg ? DOT_COLOR : "#ffffff";
const size = radius * 2.5;
ctx.fillRect(dots[i].x - radius, dots[i].y - radius, size, size);
}
}, []);
// ---- Update SVG legs directly ----
const updateLegLines = useCallback(() => {
const svg = svgRef.current;
if (!svg) return;
const legs = legsRef.current;
const lines = svg.querySelectorAll("line");
// Show/hide lines as needed
for (let i = 0; i < lines.length; i++) {
if (i < legs.length) {
lines[i].setAttribute("x2", String(legs[i].x));
lines[i].setAttribute("y2", String(legs[i].y));
lines[i].style.display = "";
} else {
lines[i].style.display = "none";
}
}
}, []);
// ---- Animation loop ----
const animate = useCallback(() => {
computeLegs();
drawDots();
updateLegLines();
rafIdRef.current = requestAnimationFrame(animate);
}, [computeLegs, drawDots, updateLegLines]);
// ---- Setup ----
useEffect(() => {
generateDots();
window.addEventListener("resize", generateDots);
// Start animation loop
rafIdRef.current = requestAnimationFrame(animate);
return () => {
window.removeEventListener("resize", generateDots);
cancelAnimationFrame(rafIdRef.current);
};
}, [generateDots, animate]);
// ---- Mouse Tracking (no setState!) ----
useEffect(() => {
const handleMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mouseRef.current.x = x;
mouseRef.current.y = y;
// Update motion values (for smooth spider body animation)
mouseX.set(x);
mouseY.set(y);
};
window.addEventListener("mousemove", handleMove);
return () => window.removeEventListener("mousemove", handleMove);
}, [mouseX, mouseY]);
// Pre-allocate SVG lines for legs (x1/y1 driven by springX/springY)
const legLines = Array.from({ length: LEG_COUNT }, (_, i) => (
<motion.line
key={i}
x1={springX}
y1={springY}
x2={0}
y2={0}
stroke="rgba(255,255,255,0.5)"
strokeWidth="1.5"
style={{ display: "none" }}
/>
));
return (
<div
ref={containerRef}
className="relative w-screen h-screen bg-black overflow-hidden"
>
{/* Dots Canvas */}
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
{/* Legs */}
<svg
ref={svgRef}
className="absolute inset-0 w-full h-full pointer-events-none"
width="100%"
height="100%"
>
{legLines}
</svg>
{/* Spider Body */}
<motion.div
style={{
width: BODY_SIZE,
height: BODY_SIZE,
x: springX,
y: springY,
translateX: -BODY_SIZE / 2,
translateY: -BODY_SIZE / 2,
}}
className="absolute bg-orange-500 cursor-pointer"
/>
</div>
);
};
export default SpiderGrid;