Robert Crocker

Craft obsessed developer who designs.

← Back to the lab

03Dot Grid Field

I · Interaction Studies

Hundreds of SVG dots scaled by proximity, painted outside the React render path.

Hover

Source

2 files
"use client";

import { useCallback, useMemo, useRef } from "react";

import { clampedNormalize, distanceBetween, type Point } from "./math";

const VIEWBOX_W = 480;
const VIEWBOX_H = 256;
const COLS = 24;
const ROWS = 13;
const BASE_R = 1.5;
const MAX_R = 7;
const INFLUENCE = 80;
const DOT_COLOR = "hsl(0 0% 74%)";
const HOVER_COLOR = "hsl(7 57% 53%)";

export function DotGridExhibit() {
  const svgRef = useRef<SVGSVGElement>(null);
  const cursorRef = useRef<Point | null>(null);
  const rafRef = useRef(0);
  const ringRef = useRef<SVGCircleElement>(null);

  const dots = useMemo(() => {
    const stepX = (VIEWBOX_W - 2 * MAX_R) / (COLS - 1);
    const stepY = (VIEWBOX_H - 2 * MAX_R) / (ROWS - 1);
    const result: Point[] = [];
    for (let r = 0; r < ROWS; r++) {
      for (let c = 0; c < COLS; c++) {
        result.push({ x: MAX_R + c * stepX, y: MAX_R + r * stepY });
      }
    }
    return result;
  }, []);

  // Direct DOM updates inside rAF — keeps hundreds of dots off the React render path
  const updateDots = useCallback(() => {
    const svg = svgRef.current;
    if (!svg) return;

    const cursor = cursorRef.current;
    const circles = svg.querySelectorAll<SVGCircleElement>("circle[data-dot]");

    for (let i = 0; i < circles.length; i++) {
      const circle = circles[i];
      const dot = dots[i];
      if (!dot) continue;

      if (!cursor) {
        circle.setAttribute("r", String(BASE_R));
        circle.setAttribute("fill", DOT_COLOR);
      } else {
        const distance = distanceBetween(cursor, dot);
        const r = clampedNormalize(distance, 0, INFLUENCE, MAX_R, BASE_R);
        circle.setAttribute("r", String(r));
        circle.setAttribute(
          "fill",
          distance < INFLUENCE ? HOVER_COLOR : DOT_COLOR
        );
      }
    }

    const ring = ringRef.current;
    if (ring) {
      if (cursor) {
        ring.setAttribute("cx", String(cursor.x));
        ring.setAttribute("cy", String(cursor.y));
        ring.style.display = "";
      } else {
        ring.style.display = "none";
      }
    }
  }, [dots]);

  const handlePointerMove = useCallback(
    (e: React.PointerEvent<SVGSVGElement>) => {
      const svg = svgRef.current;
      if (!svg) return;

      const pt = svg.createSVGPoint();
      pt.x = e.clientX;
      pt.y = e.clientY;
      const ctm = svg.getScreenCTM();
      if (!ctm) return;
      const svgPt = pt.matrixTransform(ctm.inverse());
      cursorRef.current = { x: svgPt.x, y: svgPt.y };

      cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(updateDots);
    },
    [updateDots]
  );

  const handlePointerLeave = useCallback(() => {
    cursorRef.current = null;
    cancelAnimationFrame(rafRef.current);
    rafRef.current = requestAnimationFrame(updateDots);
  }, [updateDots]);

  return (
    <div className="absolute inset-0">
      <svg
        ref={svgRef}
        viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}
        onPointerMove={handlePointerMove}
        onPointerLeave={handlePointerLeave}
        className="size-full cursor-crosshair"
        preserveAspectRatio="xMidYMid slice"
      >
        {dots.map((dot, i) => (
          <circle
            key={i}
            data-dot
            cx={dot.x}
            cy={dot.y}
            r={BASE_R}
            fill={DOT_COLOR}
            style={{ transition: "r 0.05s ease-out, fill 0.05s ease-out" }}
          />
        ))}
        <circle
          ref={ringRef}
          r={INFLUENCE}
          fill="none"
          strokeWidth={1}
          strokeDasharray="4 4"
          className="stroke-border"
          style={{ pointerEvents: "none", display: "none" }}
        />
      </svg>
    </div>
  );
}

This study grew into a long-form write-up — read the essay.