Spotlight Mouse Tracking Effect in React: Radial Gradient Cursor
Build a CSS radial gradient spotlight that follows your cursor in React. No canvas, no WebGL — just a mousemove handler and a CSS custom property.
What You're Actually Building
The spotlight mouse tracking effect is deceptively simple: a radial gradient centered on your cursor position that makes it look like a physical light is dragging across the page. You've seen it on Vercel, Linear, and dozens of other dark-themed landing pages built after 2023. It's effective because it draws the eye without demanding attention the way a spinning loader does.
Honestly, most tutorials overcomplicate this. They reach for canvas or WebGL when you really just need a mousemove listener, a CSS custom property, and a radial-gradient. That's the whole trick. Everything else is polish.
We're going to build this the right way in React — a proper reusable hook, a component that doesn't re-render on every pixel, and a fallback so it degrades gracefully on touch devices. By the end you'll have something you can drop into any project in under five minutes. If you want to see ready-made spotlight components right now, the Empire UI library has a few you can grab and tweak.
The Core Mechanism: CSS Custom Properties + mousemove
Here's the thing most devs get wrong: they update React state on every mousemove event. That triggers a re-render every 16ms, which tanks performance on anything with more than a trivial DOM. The fix is to skip React state entirely and write directly to a CSS custom property on the element.
The browser handles CSS variable updates natively — no virtual DOM diff, no reconciliation, no drama. You're just doing el.style.setProperty('--x', x + 'px') inside a requestAnimationFrame callback. Sixty frames per second, basically free.
CSS does the visual work via background: radial-gradient(circle 400px at var(--x) var(--y), rgba(255,255,255,0.08), transparent). You can swap the radius, color stops, and opacity to taste. A 400px radius with 8% white opacity reads as subtle on dark backgrounds — go above 15% and it starts looking like a flashlight rather than a spotlight.
Worth noting: pointer-events: none on the gradient overlay is non-negotiable if you want clicks to pass through to buttons and links underneath. Forget it once, you'll remember it forever.
Building the useSpotlight Hook
Start with the hook. It keeps the logic reusable and out of your component tree.
import { useEffect, useRef, useCallback } from 'react';
export function useSpotlight() {
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseMove = useCallback((e: MouseEvent) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
requestAnimationFrame(() => {
el.style.setProperty('--spotlight-x', `${x}px`);
el.style.setProperty('--spotlight-y', `${y}px`);
});
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
// Skip on touch-primary devices
if (window.matchMedia('(hover: none)').matches) return;
el.addEventListener('mousemove', handleMouseMove);
return () => el.removeEventListener('mousemove', handleMouseMove);
}, [handleMouseMove]);
return containerRef;
}The (hover: none) media query check is the graceful degradation. On phones and tablets where there's no cursor, we just don't attach the listener at all. No broken state, no jank, nothing to hide.
One more thing — using useCallback with an empty dependency array means handleMouseMove is stable across renders. If you skipped it, every render would create a new function reference and your useEffect cleanup would fire and re-register constantly. Not a disaster, but wasteful.
In practice, requestAnimationFrame wrapping the setProperty call matters less at 60fps than it does on 120Hz displays. At 120Hz you're getting mousemove events faster than the browser's style recalculation tick, so without rAF you'd be doing redundant work. Wrap it. It's one line.
The SpotlightCard Component
Now wire the hook into a component. The pattern is a container with position: relative and an absolutely-positioned overlay pseudo-element — or in React, a <div> with a pointer-events-none child.
import { useSpotlight } from './useSpotlight';
interface SpotlightCardProps {
children: React.ReactNode;
className?: string;
spotlightColor?: string;
radius?: number;
}
export function SpotlightCard({
children,
className = '',
spotlightColor = 'rgba(255, 255, 255, 0.08)',
radius = 400,
}: SpotlightCardProps) {
const containerRef = useSpotlight();
return (
<div
ref={containerRef}
className={`relative overflow-hidden ${className}`}
style={{
'--spotlight-x': '-9999px',
'--spotlight-y': '-9999px',
} as React.CSSProperties}
>
{/* Spotlight overlay */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 transition-opacity duration-300"
style={{
background: `radial-gradient(
circle ${radius}px at var(--spotlight-x) var(--spotlight-y),
${spotlightColor},
transparent 80%
)`,
}}
/>
{children}
</div>
);
}The initial --spotlight-x: -9999px is a small trick. Before the user moves their mouse, the gradient exists but is positioned way off-screen, so you don't see an awkward blob sitting in the top-left corner when the card first renders.
The transition-opacity duration-300 on the overlay is optional but feels nice — if you want to fade the spotlight in when the user enters the card area, add opacity: 0 by default and flip it to 1 with an onMouseEnter handler. That's a pure DOM operation, no state needed.
You can extend this to a full-page spotlight effect by attaching the listener to window instead of the container ref. Swap getBoundingClientRect math for raw clientX/clientY and you're done. Check out the glassmorphism components page to see how overlays like this interact with blur-heavy backgrounds — the combination is particularly striking.
Spotlight on a Dark Card Grid
The canonical use case is a grid of dark cards where each card gets its own spotlight. This is exactly what you see on Linear's feature page, Vercel's homepage circa 2024, and a dozen OSS landing pages that tried to copy them.
const features = [
{ title: 'Fast by default', desc: 'No runtime overhead.' },
{ title: 'Zero deps', desc: 'Ships 2.4kb gzipped.' },
{ title: 'Accessible', desc: 'ARIA-ready out of the box.' },
{ title: 'Themeable', desc: 'CSS variables all the way down.' },
];
export function FeatureGrid() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{features.map((f) => (
<SpotlightCard
key={f.title}
className="rounded-xl border border-white/10 bg-white/5 p-6"
spotlightColor="rgba(120, 180, 255, 0.12)"
radius={300}
>
<h3 className="text-lg font-semibold text-white">{f.title}</h3>
<p className="mt-1 text-sm text-white/60">{f.desc}</p>
</SpotlightCard>
))}
</div>
);
}Notice the spotlight color is a light blue at 12% opacity rather than pure white. Pure white reads as glare; a tinted radial reads as a colored light source. Pick a color that matches your brand accent and keep opacity under 15%. Go above that and you're in flashlight territory, not spotlight territory.
Quick aside: if your cards have a backdrop-filter: blur(12px) applied (i.e., they're glassmorphism cards), the spotlight overlay will look different because the blur flattens the layers visually. You might need to bump the spotlight opacity to 0.15–0.20 to keep it visible. The glassmorphism generator lets you preview exactly this kind of overlay interplay before committing to code.
Performance: What Actually Matters
Let's be direct about what could go wrong. The main risk with this pattern is accidental React re-renders on mousemove. If your SpotlightCard parent component holds state that changes — like a hover boolean, an active index, anything — and it re-renders on mouse movement, you'll feel it immediately on lower-end devices. The DOM write path via setProperty is fast; the React reconciliation path is not.
Profile it with React DevTools Profiler if something feels off. Filter by 'why did this render' and look for components that commit every 16ms. That's your smoking gun.
Also worth testing: will-change: background on the overlay div. In theory it promotes the element to its own compositor layer and lets the GPU handle the gradient repaint without involving the main thread. In practice, Chrome 124+ handles radial gradient updates well without it, and will-change has its own memory cost. Try it, measure it, don't add it just because you can.
One subtle thing — overflow-hidden on the container is load-bearing. Without it, the radial gradient can bleed outside the card bounds if the gradient radius is larger than the card. A 400px gradient on a 300px-wide card will spill unless clipped.
Look, if you want a production-ready implementation without sweating these details, browse components on Empire UI. The spotlight card component handles the rAF loop, the touch detection, and the overflow clipping out of the box.
Extending It: Border Spotlight and Multi-Element Tracking
Two popular variations worth knowing. First: the border spotlight. Instead of a gradient inside the card, you put the radial gradient on a pseudo-element that forms the border. The effect is a glowing border that follows the cursor, making it look like light is reflecting off the card edge.
.spotlight-border {
position: relative;
border-radius: 12px;
background: #0f0f0f;
}
.spotlight-border::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
background: radial-gradient(
circle 200px at var(--spotlight-x) var(--spotlight-y),
rgba(255, 255, 255, 0.4),
transparent 80%
);
z-index: -1;
pointer-events: none;
}This works because the ::before pseudo-element sits 1px outside the card (via inset: -1px) and the card's own background clips it, making only the 1px rim visible. It's a neat trick that's been doing the rounds since mid-2023 and still looks great.
Second variation: page-level spotlight tracking a single cursor position across all cards simultaneously. Instead of per-card refs, attach one listener to document and set the CSS vars on :root. Every card picks them up via var(--spotlight-x). This creates a different feel — more like a single light source moving across the room rather than individual card highlights. It's more dramatic, and it performs identically since you still have exactly one event listener.
That said, the per-card variant is more forgiving when cards have different backgrounds or z-index stacking. Page-level tracking with blended cards can look muddy. Pick based on your layout — dense grids usually want per-card; sparse hero sections work better with a single page-level source. For more interactive cursor ideas, check the cursors section which has several mouse-reactive patterns ready to compose.
FAQ
CSS :hover can only tell you whether the cursor is over an element — it can't give you the x/y coordinates inside it. You need JavaScript's mousemove event to get that positional data. Once you have the coords in JS, you set CSS variables and let CSS do the rendering.
There's no cursor on touch devices, so the effect simply doesn't apply. The hook checks (hover: none) via matchMedia and skips the listener entirely. Your cards render normally without the overlay visible — no broken layout, no stuck gradient blob.
Not measurably, provided you're using the requestAnimationFrame pattern and writing directly to CSS custom properties rather than triggering React re-renders. The overlay is a single GPU-composited layer with no layout shift and no DOM node count increase worth worrying about.
Yes, and it looks great. Just be aware that backdrop-filter blur flattens your layer stack visually, so you may need to bump the spotlight opacity from 0.08 to around 0.15 to keep it visible through the blur. Test it in dark mode specifically — contrast behaves differently there.