How to build a mouse-reactive dot grid hero in React
A mouse-reactive dot grid hero in React renders an SVG grid of circles and, on each pointer move, directly mutates each dot's radius and opacity based on its Euclidean distance from the cursor, no re-renders, no state. Framer Motion motion values pipe the coordinates; a useCallback subscriber reads them and calls setAttribute on the SVGCircleElement ref.
- Stack: React 19 + Framer Motion 11 + lucide-react, ~190 lines, no extra dependencies.
- 24 columns × 14 rows = 336 dots, each gap-32px, centered in the section via SVG transform.
- Performance: zero React re-renders on mouse move; DOM writes go straight to the SVG element via setAttribute.
- Accessible: the dot grid carries aria-hidden so screen readers skip it entirely.
- Touch caveat: no pointer on mobile, so dots stay at their resting opacity (0.15) with no interaction.
Hero Particle Grid is a split-layout React hero where a full-bleed SVG dot grid reacts to the pointer in real time. Dots within 120px of the cursor swell from 2px to 6px and brighten, creating a depth-field spotlight that follows the mouse without a single React state update. The layout pairs the animated background with a left-aligned headline, description and primary CTA.
Anatomy
The section is a full-viewport flex container (min-height 90vh) with the DotGrid positioned absolutely behind the content. DotGrid renders an SVG element (COLS × GAP wide, ROWS × GAP tall) centered with a CSS transform translate(-50%, -50%). Each of the 336 dots is a Dot component that holds a ref to its SVGCircleElement. The text block sits in a relative z-index:1 container capped at 600px, with a staggered Framer Motion entrance animation (h1 → p → CTA buttons, each delayed by 0.1s).
How it works
Two Framer Motion motion values, mouseX and mouseY, are created at the DotGrid level and initialised far off-screen (-1000, -1000). On each mousemove the handler reads the container's bounding rect and writes the cursor's container-relative position into them. Every Dot subscribes to both motion values via the .on('change', ...) API, when either fires, the dot computes its Euclidean distance to the cursor and a scale factor from 0 to 1 based on a 120px radius. It then calls ref.current.setAttribute to set r and opacity directly on the SVG element, bypassing React's reconciler entirely. On mouse leave, the values reset to -1000 so every dot falls back to its resting state (r=2, opacity=0.15).
How to build it in React
Build the SVG dot grid
Define COLS, ROWS and GAP as module-level constants. In DotGrid, loop over every row/col combination and push a Dot at position (col * GAP, row * GAP). Wrap the SVG in an absolutely-positioned div with overflow:hidden and aria-hidden.
const COLS = 24, ROWS = 14, GAP = 32, DOT_SIZE = 2; // inside DotGrid render: for (let row = 0; row < ROWS; row++) for (let col = 0; col < COLS; col++) dots.push(<Dot key={`${row}-${col}`} cx={col*GAP} cy={row*GAP} mouseX={mouseX} mouseY={mouseY} />);Wire motion values to the pointer
Create mouseX and mouseY with useMotionValue(-1000) at DotGrid level. Pass them down to each Dot as props. In the onMouseMove handler, subtract the container's getBoundingClientRect to get container-relative coordinates, then call mouseX.set() and mouseY.set().
const mouseX = useMotionValue(-1000); const mouseY = useMotionValue(-1000); const handleMouseMove = useCallback((e: React.MouseEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; mouseX.set(e.clientX - rect.left); mouseY.set(e.clientY - rect.top); }, [mouseX, mouseY]);Subscribe each dot and write to the DOM directly
Inside the Dot component, hold a ref to the SVGCircleElement. Register a subscriber with mouseX.on('change', updateDot) and mouseY.on('change', updateDot). In updateDot, compute the distance, derive r and opacity, and call setAttribute on the ref, no setState, no re-render.
const updateDot = useCallback(() => { if (!ref.current) return; const dx = mouseX.get() - cx, dy = mouseY.get() - cy; const dist = Math.sqrt(dx*dx + dy*dy); const scale = Math.max(0, 1 - dist / 120); ref.current.setAttribute("r", String(DOT_SIZE + scale * 4)); ref.current.setAttribute("opacity", String(0.15 + scale * 0.6)); }, [cx, cy, mouseX, mouseY]); mouseX.on("change", updateDot); mouseY.on("change", updateDot);Animate the text block on mount
Wrap h1, p and the CTA row in motion.* elements with initial={{ opacity: 0, y: 24/14/8 }} and animate={{ opacity: 1, y: 0 }}. Stagger the delays (0, 0.1, 0.2s) and use the cubic-bezier [0.16, 1, 0.3, 1] for a crisp out-expo feel that pairs well with the grid's subtlety.
const EASE = [0.16, 1, 0.3, 1] as const; <motion.h1 initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease: EASE }} >
When to use it
This hero works best on SaaS or developer-tool landing pages where you want a technical, precise aesthetic without heavy canvas or WebGL. The interaction reads as subtle sophistication rather than spectacle. Skip it when your above-the-fold goal is pure conversion speed, 336 SVG elements add a small parse cost, and always test on low-end Android devices to confirm the interaction stays smooth.
Used by
- Stripe, Uses animated dot/grid backgrounds on product pages to convey technical precision without heavy visual noise.
- Resend, Adopts sparse dot grids and proximity-reactive cues in hero sections targeting developers.
- Clerk, Combines left-aligned hero copy with subtle animated backgrounds to strike a clean yet interactive tone.
- Raycast, Deploys pointer-reactive grain and particle layers on its landing hero to reward exploratory mouse movement.
FAQ
Why subscribe to motion values instead of using onMouseMove state?
Motion values fire outside React's render cycle. Writing coordinates to useState would trigger 336 re-renders on every pointer event, typically 60 times per second. The .on('change') subscriber approach writes directly to the SVG DOM, keeping the main thread free and the animation smooth even at high cursor speeds.
How do I change the influence radius?
Edit the maxDist constant inside the Dot's updateDot callback (currently 120px). A larger value spreads the glow across more dots; a smaller one creates a tighter, more focused spotlight. The scale formula `Math.max(0, 1 - dist / maxDist)` handles the falloff automatically.
Can I change the dot color per theme?
Yes. Each circle uses fill="var(--color-accent)" so swapping the CSS custom property on the parent element is enough. The component respects all seven registry theme presets out of the box.
What happens on touch screens?
Nothing, touch events do not fire mousemove, so the motion values stay at their initial -1000 position and all dots render at their resting state (r=2, opacity=0.15). The section remains fully usable; it just loses the interactive layer. If you want a fallback, detect touch on mount and swap the grid for a static version.