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

default.tsx
"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;