Around 2023, a lot of marketing sites and homepages started doing the same thing: hover a feature card and a soft circle of light tracked your cursor across the surface. The effect spread fast, got bottled up into entire component libraries, and within a year it was the visual handshake of the whole "design engineer on Twitter" aesthetic.
It's pure feedback. The card doesn't do anything — no link changes, no menu opens, no preview loads. It just signals "this object is interactive, the cursor is here right now," and somehow that's enough. Once a site has it, sites without it feel a little dead. Move slowly across the cards below and watch the spotlight track exactly where you are:
Plug & Play
Drop into any React app — zero config required.
Built for the Web
Optimized for modern browsers and JS engines.
Open Source
MIT licensed, on GitHub, contributions welcome.
Dissecting the interaction
There's a radial-gradient painted into an absolutely-positioned overlay div sitting on top of the card. The gradient's center coordinates are bound to the cursor's position relative to the card, so every mousemove event nudges them. The overlay has pointer-events-none so it doesn't intercept clicks meant for the content underneath.
Two pieces of state per card: an { x, y } for the gradient center, and a separate opacity that fades the whole overlay in on mouseenter and out on mouseleave. A transition-opacity duration-300 smooths that fade so the spotlight doesn't pop on and off.
Why it feels good
- It tracks the actual cursor, not the element center. A static glow that switches on and off feels like a different effect entirely — a button highlight. The cursor-following part is what makes it feel like light, not state.
pointer-events-noneon the overlay means clicks pass straight through to the title, the link, whatever you put inside. The spotlight is decoration; the card still works.- Per-card state, not shared state. Three cards means three independent
useStatecalls. Hoist that state to a parent and all three spotlights move in lockstep, which immediately reads as a bug.
The shape of the code
function Card({ title, body }: CardProps) {
const x = useMotionValue(-200)
const y = useMotionValue(-200)
const opacity = useMotionValue(0)
const background = useMotionTemplate`radial-gradient(400px circle at ${x}px ${y}px, rgba(0,122,255,0.18), transparent 40%)`
const handleMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
x.set(e.clientX - rect.left)
y.set(e.clientY - rect.top)
}
return (
<div onMouseMove={handleMove} onMouseEnter={() => opacity.set(1)} onMouseLeave={() => opacity.set(0)}>
<motion.div
className="pointer-events-none absolute -inset-px transition-opacity duration-300"
style={{ opacity, background }}
/>
{/* title + body */}
</div>
)
}Initial values are -200 for x and y so the gradient sits off-screen until the first hover. Using e.currentTarget.getBoundingClientRect() inside the handler also means you don't need a ref to make the math work. The motion values are the important detail at scale: setting x.set(...) writes straight to the DOM via motion's render pipeline, so dragging the cursor across the card doesn't trigger a React re-render per mousemove — important once you have hundreds of these on a page.
Just for funsies: Shaders!
The core math here is "brightness equals one minus distance from the cursor" — barely anything. That's a few lines in GLSL:
void main() {
float d = distance(gl_FragCoord.xy, u_mouse) / u_resolution.y; // pixel distance from cursor, scaled to card-heights so x and y use the same unit
float glow = 1.0 - smoothstep(0.0, 1.2, d);
vec3 col = mix(vec3(1.0), vec3(0.0, 0.478, 1.0), glow * 0.18); // start white, blend toward blue near the cursor (max 18%)
gl_FragColor = vec4(col, 1.0);
}A single full-screen quad, three uniforms (u_resolution, u_mouse, u_time), and you get the same cursor-tracking falloff as a pure pixel computation on the GPU. Move your cursor across the canvas:
Plug & Play
Drop into any React app — zero config required.
Built for the Web
Optimized for modern browsers and JS engines.
Open Source
MIT licensed, on GitHub, contributions welcome.
But wait
The visual is essentially the same. So which one's "better"? For this effect, the CSS version wins on every dimension that matters — and it's worth being specific about why:
- Browser cost. Each shader card holds a live WebGL context. Browsers cap you at ~16, after which contexts get dropped. The CSS gradient costs nothing extra — the browser composites it like any other layer.
- Server rendering. The CSS version SSRs as plain HTML and starts working as soon as the mouse moves. The shader version needs a
mountedgate (ordynamic({ ssr: false })) because there's nodocumentorWebGLon the server. - Always running. The CSS version only does work on
mousemove. The shader version'srequestAnimationFramekeeps firing whether anything's changing or not — fine for a few cards, wasteful at scale. - Less code. The CSS version is one
radial-gradientstring and two pieces of state. The shader version is a vertex shader, a fragment shader, a compile pipeline, a buffer of vertex positions, three uniform location lookups, aResizeObserverfor DPR, arequestAnimationFrameloop, and Y-flipping mouse coordinates becausegl_FragCoordis bottom-left while DOM events are top-left. Both produce the same pixels. - Accessibility. The CSS card's heading and body are real DOM nodes screen readers can find. The shader card's content has to be overlaid as separate DOM on top of an opaque canvas, and getting text legibility right takes extra work (
mix-blend-mode, halos, or backdrops) since the shader's bg colors fight you.
The shader is the right tool when the effect needs computation CSS can't express — a custom falloff curve, multiple interacting light sources, per-pixel noise or distortion, a bg image you're warping. For "soft circle of light follows the cursor", radial-gradient already maps onto the GPU compositor, just through a much shorter path. Reach for shaders when the picture you want isn't expressible with gradient stops; reach for CSS the rest of the time.