SpotlightGrid
The dot-grid-plus-mouse-spotlight hero treatment this site itself is built on, packaged as a drop-in React component.
npm install react react-domThis is the literal component behind the hero on this site’s homepage — the dot grid you’re looking at right now, plus the spotlight that follows your cursor over it. Shipping the thing the site itself uses felt like a more honest portfolio piece than a synthetic demo, so that’s what’s below: the real file, unmodified.
Move your mouse over the panel below (desktop, mouse or trackpad only — it’s inert on touch).
Move your mouse over this panel
Why coordinates go to a CSS variable, not useState
The naive version of this component calls setState({ x, y }) on every pointermove and lets React re-render the tree on every one of those updates. On a component this simple that’s survivable — but it’s the wrong default to teach, and on a busier page (this one has cards, marquees, and other listeners nearby) it adds up to dropped frames the moment the pointer moves fast.
SpotlightGrid never calls setState for the pointer position. The pointermove handler writes straight to two CSS custom properties — --spotlight-x and --spotlight-y — on the container’s own style object, and the spotlight itself is a CSS radial-gradient() on a ::after pseudo-element that reads those two properties. Updating a custom property is a style recalculation, not a React commit — React’s render function runs exactly once per mount (and once more only if intensity or children change, since those are real props). The DOM does the animating; React just sets it up and gets out of the way.
Why the writes are coalesced through requestAnimationFrame
A fast mouse can fire pointermove well over 100 times a second — far more often than the screen repaints. Writing the CSS variable on every single event does needless work between paints. The handler instead stashes the latest coordinate in a plain variable and schedules one requestAnimationFrame callback if one isn’t already pending. However many pointermove events land in a given frame, only the last one wins, and the DOM is touched at most once per frame — matched to the display’s actual refresh rate instead of the input device’s polling rate.
Why it’s bound to the container, not window
The homepage hero listens on window because it needs the spotlight to track the pointer across the whole hero section from the moment it mounts. This packaged version listens on its own container element instead, using pointerenter / pointerleave to fade the spotlight in and out. That’s the more reusable default: drop three of these on a page and you get three independent listeners, each scoped to its own box, instead of one window listener doing arithmetic for all three on every move.
Guards: pointer type, reduced motion, and SSR
Three checks happen before any listener is attached, all inside useEffect so none of them ever run during server rendering:
(pointer: fine)— touch and coarse-pointer devices skip the listener entirely. There is no hover state to chase on a phone.(prefers-reduced-motion: reduce)— the spotlight is decorative; users who’ve asked for less motion don’t get it, full stop. The CSS also carries its own@media (prefers-reduced-motion: reduce)rule as a second line of defense in case the effect somehow gets applied outside the guarded code path.- No
windowaccess during render — everywindow/documentread happens insideuseEffect, which never runs on the server. With JavaScript disabled entirely, or before hydration, you get the dot grid and nothing else: no error, no flash of missing styling, no layout shift, because the spotlight is anopacity: 0pseudo-element from the first paint.
The listeners are removed in the effect’s cleanup function on unmount, along with any pending animation frame — nothing is left running against a detached node.
Files
import { useEffect, useRef, type ReactNode } from 'react';
import './spotlight-grid.css';
export interface SpotlightGridProps {
/** Extra classes on the container (set a height — the grid has none of its own). */
className?: string;
/** Dot-grid cell size in px. Site default is 32. */
size?: number;
/** Spotlight opacity on hover, 0-1. Site default is 0.85. */
intensity?: number;
children?: ReactNode;
}
/**
* The site's own hero treatment, packaged: a 32px dot grid with a
* mouse-following radial spotlight.
*
* Performance note: pointer coordinates are written straight to CSS custom
* properties on the container element, not to React state. That means
* moving the mouse never triggers a re-render.
*/
export default function SpotlightGrid({
className = '',
size = 32,
intensity = 0.85,
children
}: SpotlightGridProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!window.matchMedia('(pointer: fine)').matches) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let frame = 0;
let pending: { x: number; y: number } | null = null;
function flush() {
frame = 0;
if (!pending || !el) return;
el.style.setProperty('--spotlight-x', `${pending.x}px`);
el.style.setProperty('--spotlight-y', `${pending.y}px`);
pending = null;
}
function handleMove(event: PointerEvent) {
if (!el) return;
const rect = el.getBoundingClientRect();
pending = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (!frame) frame = requestAnimationFrame(flush);
}
function handleEnter() {
el?.style.setProperty('--spotlight-opacity', String(intensity));
}
function handleLeave() {
el?.style.setProperty('--spotlight-opacity', '0');
}
el.addEventListener('pointermove', handleMove);
el.addEventListener('pointerenter', handleEnter);
el.addEventListener('pointerleave', handleLeave);
return () => {
el.removeEventListener('pointermove', handleMove);
el.removeEventListener('pointerenter', handleEnter);
el.removeEventListener('pointerleave', handleLeave);
if (frame) cancelAnimationFrame(frame);
};
}, [intensity]);
return (
<div
ref={ref}
className={`spotlight-grid ${className}`}
style={{ ['--spotlight-size' as string]: `${size}px` } as React.CSSProperties}
>
{children}
</div>
);
}.spotlight-grid {
--spotlight-x: 50%;
--spotlight-y: 50%;
--spotlight-opacity: 0;
--spotlight-size: 32px;
position: relative;
isolation: isolate;
background-color: #f9fafb;
background-image: radial-gradient(#94a3b8 1px, transparent 1px);
background-size: var(--spotlight-size) var(--spotlight-size);
}
.spotlight-grid::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
opacity: var(--spotlight-opacity);
transition: opacity 200ms ease-out;
background: radial-gradient(
600px circle at var(--spotlight-x) var(--spotlight-y),
rgba(255, 255, 255, 0.85),
transparent 45%
);
}
@media (prefers-reduced-motion: reduce) {
.spotlight-grid::after {
display: none;
}
}import SpotlightGrid from './SpotlightGrid';
export default function Hero() {
return (
<SpotlightGrid className="min-h-[420px] w-full rounded-2xl" size={32} intensity={0.85}>
<div className="relative z-10 p-16">
<h1 className="text-6xl font-black tracking-tight">Your headline here</h1>
</div>
</SpotlightGrid>
);
}className and intensity are the two knobs worth touching: give the container a height (it has none of its own — that’s a caller decision, same as this site’s own hero), and drop intensity if the effect reads as too bright against a busy background. Everything else — the coalescing, the guards, the cleanup — is not meant to be reopened.
Alexander built this resource for a real project first — the write-up covers why it's shaped the way it is.
No spam. One or two emails a month, unsubscribe anytime.