Spotlight Card Effect in React: Cursor-Tracking Glow on Hover
Build a cursor-tracking spotlight glow on cards in React using mouse events and CSS radial gradients — no canvas, no library, just clean hook-based logic.
What the Spotlight Card Effect Actually Is
The spotlight card effect is deceptively simple: as your cursor moves over a card, a soft radial glow follows it — always centered on wherever your mouse is right now. The card itself doesn't move. There's no tilt, no parallax. Just light, tracking your pointer like a flashlight sweeping across a dark surface.
You've seen it on Vercel's homepage, Linear's marketing pages, and a dozen SaaS landing pages that shipped in 2024 and 2025. It's become one of those micro-interactions that quietly signals "this product is well-crafted" without screaming for attention. Done right, it's subtle. Done wrong, it's a radioactive blob chasing your cursor around the screen.
The implementation lives entirely in CSS and a bit of JavaScript — specifically a mousemove event listener and a radial-gradient on the card's ::before pseudo-element (or an overlay div). No canvas required. No WebGL. No 300kb animation library. This is the kind of thing that takes 40 lines of code and looks like it took a week.
Worth noting: there's a close cousin called the spotlight grid effect, where the glow reveals a dot-grid or noise texture underneath. That's a slightly different beast. This article covers the pure card variant — one component, one glow, one hook. We'll get to the grid variant in a future post.
How the Math Works (It's Just Two Numbers)
The whole trick is converting the mouse's page coordinates into coordinates relative to the card element. getBoundingClientRect() gives you the card's position on screen. Subtract the card's left and top from event.clientX and event.clientY, and you have x/y values in the card's own coordinate system. Feed those into a CSS custom property and write a radial-gradient that uses those properties as its center position.
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
e.currentTarget.style.setProperty('--mouse-x', `${x}px`);
e.currentTarget.style.setProperty('--mouse-y', `${y}px`);
};That's it. The CSS does the rest. You write something like radial-gradient(600px circle at var(--mouse-x) var(--mouse-y), rgba(255,255,255,0.08), transparent 40%) and the browser calculates the entire gradient every frame, right where the cursor sits. No requestAnimationFrame, no lerp, no state updates — just CSS doing what CSS was designed for.
Honestly, the reason this works so well is that browsers are extremely good at painting gradients. You're not causing layout recalculations. You're not even triggering a React re-render. Setting a CSS custom property directly on a DOM node via style.setProperty is a paint-only operation, which means it runs on the compositor thread at 60fps (or 120fps on high-refresh displays) without touching your React tree at all.
One more thing — the gradient radius matters a lot. 200px feels tight and precise. 600px feels like a wide ambient glow. 1000px is basically a full-card color wash. In practice, 400–600px is the sweet spot for most card sizes between 300px and 500px wide.
Building the SpotlightCard Component
Here's a production-ready component. It handles mouse enter/leave to reset the glow, accepts a glowColor prop so you can theme it per-card, and keeps the overlay as a sibling div rather than a pseudo-element so you can mix it with Tailwind without fighting CSS specificity.
// SpotlightCard.tsx
import { useRef, ReactNode, CSSProperties } from 'react';
interface SpotlightCardProps {
children: ReactNode;
className?: string;
glowColor?: string;
glowSize?: number;
}
export function SpotlightCard({
children,
className = '',
glowColor = 'rgba(255, 255, 255, 0.08)',
glowSize = 500,
}: SpotlightCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const card = cardRef.current;
if (!card) return;
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
card.style.setProperty('--spotlight-x', `${x}px`);
card.style.setProperty('--spotlight-y', `${y}px`);
card.style.setProperty('--spotlight-opacity', '1');
};
const handleMouseLeave = () => {
const card = cardRef.current;
if (!card) return;
card.style.setProperty('--spotlight-opacity', '0');
};
return (
<div
ref={cardRef}
className={`relative overflow-hidden rounded-2xl border border-white/10 bg-neutral-900 ${className}`}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{
'--spotlight-opacity': '0',
'--spotlight-x': '50%',
'--spotlight-y': '50%',
} as CSSProperties}
>
{/* Spotlight overlay */}
<div
className="pointer-events-none absolute inset-0 transition-opacity duration-300"
style={{
opacity: 'var(--spotlight-opacity)',
background: `radial-gradient(${glowSize}px circle at var(--spotlight-x) var(--spotlight-y), ${glowColor}, transparent 40%)`,
}}
/>
{children}
</div>
);
}A few decisions worth calling out. The overflow-hidden on the card is non-negotiable — without it, the gradient bleeds outside the border radius and the effect looks broken. The pointer-events-none on the overlay means clicks pass straight through to your card content. And the transition-opacity duration-300 on the overlay gives you a smooth fade-out when the cursor leaves instead of a jarring instant disappearance.
The --spotlight-opacity CSS variable starts at 0 and jumps to 1 on first mouse move. That CSS transition makes it feel intentional rather than accidental. You could add a fade-in delay too — transition: opacity 150ms ease 50ms — so the spotlight doesn't appear until the user has hovered for a moment, which feels more elegant on cards that are part of a grid.
Quick aside: if you're using Tailwind v4 (released in early 2025), you can write opacity-(--spotlight-opacity) and bg-(--spotlight-gradient) directly in className strings using the new arbitrary CSS variable syntax. It's cleaner. For Tailwind v3, inline styles for dynamic values is the right call.
Theming the Glow for Different Card Types
The glowColor prop is where things get fun. Your default rgba(255,255,255,0.08) gives you a neutral white glow that works on any dark card. But you can theme each card independently — a teal glow for a "development" tier card, a purple glow for a "pro" card, an amber glow for a featured call-to-action.
{/* Pricing card grid */}
<div className="grid grid-cols-3 gap-6">
<SpotlightCard glowColor="rgba(99,102,241,0.15)">
<PricingTier name="Starter" price="$0" />
</SpotlightCard>
<SpotlightCard glowColor="rgba(168,85,247,0.2)" glowSize={600}>
<PricingTier name="Pro" price="$19" featured />
</SpotlightCard>
<SpotlightCard glowColor="rgba(20,184,166,0.15)">
<PricingTier name="Team" price="$49" />
</SpotlightCard>
</div>In practice, you want the glowColor opacity between 0.08 and 0.25. Below 0.08 and most users won't even notice the effect. Above 0.25 and it starts looking like a mistake rather than a design choice. The "pro" or featured card is the one place you can push it higher — 0.3 to 0.4 — because you *want* it to stand out from its siblings.
If you're building glassmorphism components and want the spotlight to interact with the glass surface, set glowColor to a color from your background gradient at low opacity. A blue-purple gradient background with a rgba(139,92,246,0.12) spotlight will look like light refracting through the glass. Pair it with the glassmorphism generator to dial in the backdrop-blur and the glow together.
The Group Spotlight: One Glow Across a Card Grid
There's a more advanced variant where the spotlight is tracked at the *grid container* level, and each card renders the gradient independently — giving you one continuous light source that sweeps across multiple cards as if they were all illuminated by the same overhead lamp. This is what you see on Vercel's feature grid and on a few Empire UI template layouts.
// SpotlightGrid.tsx
import { useRef, ReactNode } from 'react';
export function SpotlightGrid({ children }: { children: ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container) return;
const cards = container.querySelectorAll<HTMLElement>('[data-spotlight-card]');
cards.forEach((card) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
card.style.setProperty('--spotlight-x', `${x}px`);
card.style.setProperty('--spotlight-y', `${y}px`);
card.style.setProperty('--spotlight-opacity', '1');
});
};
const handleMouseLeave = () => {
const container = containerRef.current;
if (!container) return;
const cards = container.querySelectorAll<HTMLElement>('[data-spotlight-card]');
cards.forEach((card) => {
card.style.setProperty('--spotlight-opacity', '0');
});
};
return (
<div
ref={containerRef}
className="grid grid-cols-3 gap-4"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{children}
</div>
);
}
// Each card child just adds data-spotlight-card + the CSS variable stylesThe querySelectorAll('[data-spotlight-card]') approach keeps the parent and child components loosely coupled. The parent doesn't need to know about children through React context or prop drilling — it just finds the right DOM nodes by attribute. That said, if you're doing this in a highly dynamic list where cards mount and unmount frequently, a React context approach with a useContext hook on each card is cleaner because you avoid querying the DOM on every mousemove.
Worth noting: querySelectorAll on every mousemove fires *a lot*. On a grid of 9 cards, that's 9 DOM lookups at potentially 120 calls per second. Cache the NodeList in a useRef that you populate in a useEffect, and update it only when the card count changes. The performance difference is measurable on low-end Android devices.
Look, most of the time you don't need the group variant. Individual per-card spotlight is 90% of use cases and it's dramatically simpler. Start there. If a designer specifically asks for the "sweeping light across the grid" look, reach for the group version.
Accessibility, Reduced Motion, and Touch Devices
The spotlight effect is purely decorative — it carries no information, it doesn't change functionality, and keyboard users never see it because they're navigating with Tab, not a pointer. That makes it safe from a WCAG perspective, but you should still think about a few edge cases.
Touch devices fire touchmove events, not mousemove. The spotlight will simply never appear on an iPhone or Android phone, which is fine — the cards should look good without it. Don't wire up touchmove to power the spotlight on mobile; the performance cost isn't worth it and the gesture conflicts with scroll.
// Respecting prefers-reduced-motion
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
// Check preference at event time (avoids a useEffect + state)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// ... rest of handler
};Honestly, prefers-reduced-motion is a bit overkill for a static spotlight that doesn't animate on its own — the glow is continuous, not pulsing or flashing. But some users are genuinely sensitive to any kind of motion, including smooth opacity transitions. Adding the matchMedia check is three lines and makes the component fully respectful of user preferences. Just do it.
One more thing — if you're stacking spotlight cards inside a scroll container (not the viewport), event.clientX/Y still works correctly because getBoundingClientRect() always returns viewport-relative coordinates regardless of scroll position. You don't need to account for scrollTop or scrollLeft. This is a common gotcha that trips people up when they first implement this.
Where to Use It (and Where Not To)
Spotlight cards shine (sorry) on marketing pages, feature grids, pricing sections, portfolio pieces, and dark-themed dashboards. The effect requires a dark or deeply saturated background to read clearly — on a white #ffffff card with rgba(255,255,255,0.1) glow, you'll see nothing. This is fundamentally a dark-UI technique. It pairs naturally with Empire UI's cyberpunk, aurora, and vaporwave style families.
Why does it work so well on pricing pages specifically? Because the cursor glow draws attention to whichever card the user is actively considering. It creates a natural "selection" feel without using a border or background color that permanently highlights one option. The user's own movement generates the emphasis. It's subtle UX psychology baked into CSS.
Where you shouldn't use it: forms, data tables, text-heavy content areas, and anywhere the user needs to focus on reading rather than exploring. The movement is distracting when someone is trying to fill in a field or parse a number. Reserve it for browsing-mode UI — the parts of your page where the user is still deciding what to click, not the parts where they've already decided.
If you want to see the effect in context alongside other hover techniques like tilt, border glow, and magnetic pull, the card hover effects CSS article covers the full family. And if you're building the surrounding page, check out the gradient generator to dial in a background gradient that makes your spotlight cards pop — the deeper the background contrast, the more dramatic the glow.
FAQ
Yes — add a mousemove event listener in vanilla JS, grab getBoundingClientRect(), and set CSS custom properties directly. The React component wrapper is just convenience; the core technique is framework-agnostic.
Almost always a background color issue. The radial-gradient glow needs a dark or saturated background to be visible. On light cards, switch to a colored glow like rgba(99,102,241,0.3) instead of white, or move to a dark theme.
No, because you're setting CSS custom properties directly on DOM nodes — not updating React state. The browser handles the paint on the compositor thread. 20 spotlight cards on screen is not a problem on any modern device.
Absolutely. Set glowColor to match your background gradient at low opacity, keep backdrop-blur on the card, and the spotlight reads as light refracting through the glass surface. The glassmorphism generator can help you balance the blur and glow values.