EmpireUI
Get Pro
← Blog8 min read#spotlight#card#react

Spotlight Card in React: Cursor-Tracking Radial Highlight Effect

Build a cursor-tracking spotlight card in React using mousemove events and CSS radial gradients — no canvas, no library, just clean component code.

glowing radial light highlight moving across a dark card surface

What the Spotlight Card Effect Actually Is

You've seen it on product landing pages — hover a card and a soft glow follows your cursor like a flashlight sweeping across the surface. That's the spotlight card. It feels interactive, a bit magical, and it takes maybe 60 lines of React to pull off from scratch.

The mechanic is dead simple: you track mousemove events on the card element, grab the cursor's position relative to the card's bounding box, then feed those coordinates into a CSS radial-gradient applied as a background or background-image. The gradient follows the cursor. That's it. No canvas, no WebGL, no third-party animation library required.

In practice, the effect reads as significantly more polished than the code behind it. It's one of those small wins that's worth reaching for on portfolio sites, SaaS pricing cards, or any place where you want users to slow down and actually read the content. Worth noting: it pairs especially well with dark backgrounds — the contrast between the dim card surface and the lit region is what sells the illusion. If you're pairing this with frosted surfaces, check out the glassmorphism components in Empire UI — the two styles stack beautifully.

Honestly, the tricky part isn't writing the effect — it's writing it so it doesn't tank your render performance. We'll cover both the naive approach and a version that skips React re-renders entirely by writing directly to CSS custom properties.

The Naive useState Approach (and Why You'll Outgrow It)

Start here so you understand what you're eventually optimizing away. The most direct implementation uses useState to store cursor coordinates and re-renders the card on every mousemove event.

import { useState, useRef, MouseEvent } from 'react';

export function SpotlightCard({ children }: { children: React.ReactNode }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  const [isHovered, setIsHovered] = useState(false);
  const cardRef = useRef<HTMLDivElement>(null);

  function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
    const rect = cardRef.current!.getBoundingClientRect();
    setPos({
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    });
  }

  const gradient = isHovered
    ? `radial-gradient(300px circle at ${pos.x}px ${pos.y}px, rgba(255,255,255,0.12), transparent 60%)`
    : 'none';

  return (
    <div
      ref={cardRef}
      onMouseMove={handleMouseMove}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      style={{ background: gradient }}
      className="relative rounded-2xl border border-white/10 bg-gray-900 p-6"
    >
      {children}
    </div>
  );
}

This works. On a modern machine you won't notice the perf hit with one or two cards. But mousemove fires at up to 250 events per second, and each one queues a React re-render. If you've got a grid of six cards or you're running on a mid-range Android device, you'll feel it — subtle jank on fast cursor sweeps.

That said, this version is perfect for prototyping and totally fine if your spotlight card is an isolated hero element. Shipping something that works beats endlessly optimizing something that doesn't exist yet.

Quick aside: the 300px in the radial-gradient circle size is a good starting point. Go smaller (150px) for a tight, focused beam; go larger (500px) for a soft ambient glow that barely reads as directional. I usually land around 250–350px for cards in the 320–480px width range.

The Performant Version: CSS Custom Properties Without Re-Renders

Here's the version you actually want in production. Instead of calling setState on every mouse event, you write directly to CSS custom properties on the DOM node. React never sees the update. Zero re-renders.

import { useRef, useCallback, MouseEvent } from 'react';

type SpotlightCardProps = {
  children: React.ReactNode;
  spotlightColor?: string;
  spotlightSize?: number;
  className?: string;
};

export function SpotlightCard({
  children,
  spotlightColor = 'rgba(255,255,255,0.10)',
  spotlightSize = 300,
  className = '',
}: SpotlightCardProps) {
  const cardRef = useRef<HTMLDivElement>(null);

  const handleMouseMove = useCallback((e: MouseEvent<HTMLDivElement>) => {
    const el = cardRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    el.style.setProperty('--x', `${e.clientX - rect.left}px`);
    el.style.setProperty('--y', `${e.clientY - rect.top}px`);
  }, []);

  const handleMouseEnter = useCallback(() => {
    cardRef.current?.style.setProperty('--spotlight-opacity', '1');
  }, []);

  const handleMouseLeave = useCallback(() => {
    cardRef.current?.style.setProperty('--spotlight-opacity', '0');
  }, []);

  return (
    <div
      ref={cardRef}
      onMouseMove={handleMouseMove}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      style={{
        '--x': '50%',
        '--y': '50%',
        '--spotlight-size': `${spotlightSize}px`,
        '--spotlight-color': spotlightColor,
        '--spotlight-opacity': '0',
      } as React.CSSProperties}
      className={`spotlight-card ${className}`}
    >
      {children}
    </div>
  );
}

Then wire it up in your CSS (or a <style> tag if you're going CSS-in-JS-free):

.spotlight-card {
  position: relative;
  border-radius: 1rem;
  border: 1px solid rgba(255, 255, 255, 0.08);
  background-color: #0f0f0f;
  padding: 1.5rem;
  overflow: hidden;
}

.spotlight-card::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(
    var(--spotlight-size) circle at var(--x) var(--y),
    var(--spotlight-color),
    transparent 60%
  );
  opacity: var(--spotlight-opacity);
  transition: opacity 0.3s ease;
  pointer-events: none;
}

The ::before pseudo-element handles the gradient. The opacity transition gives you a smooth fade-in when the cursor enters and a fade-out on exit — without animating the gradient position itself, which would feel sticky and wrong. And because you're mutating style properties directly, the browser paints exactly once per frame via its own compositor, not via React's diffing algorithm.

Look, this pattern — CSS custom properties as a React escape hatch — is useful far beyond spotlight effects. Any time you need to sync DOM state with fast input events (drag, scroll, gyroscope), reach for it first.

A Grid of Spotlight Cards with Shared Parent Tracking

Individual card tracking is fine, but there's an even cooler variant: track the cursor on the *parent grid container* and let every card in the grid react simultaneously. You get a group-level illumination effect where the spotlight sweeps across the whole layout.

import { useRef, useCallback } from 'react';

type GridSpotlightProps = {
  cards: { title: string; description: string }[];
};

export function GridSpotlight({ cards }: GridSpotlightProps) {
  const gridRef = useRef<HTMLDivElement>(null);

  const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
    const children = gridRef.current?.querySelectorAll<HTMLDivElement>('.spotlight-cell');
    children?.forEach((card) => {
      const rect = card.getBoundingClientRect();
      card.style.setProperty('--x', `${e.clientX - rect.left}px`);
      card.style.setProperty('--y', `${e.clientY - rect.top}px`);
    });
  }, []);

  return (
    <div
      ref={gridRef}
      onMouseMove={handleMouseMove}
      className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
    >
      {cards.map((card, i) => (
        <div
          key={i}
          className="spotlight-cell spotlight-card"
          style={{
            '--x': '-9999px',
            '--y': '-9999px',
            '--spotlight-size': '400px',
            '--spotlight-color': 'rgba(139, 92, 246, 0.15)',
            '--spotlight-opacity': '1',
          } as React.CSSProperties}
        >
          <h3 className="text-lg font-semibold text-white">{card.title}</h3>
          <p className="mt-2 text-sm text-gray-400">{card.description}</p>
        </div>
      ))}
    </div>
  );
}

Setting the initial --x to -9999px parks the gradient off-screen so it doesn't render in the top-left corner before the user moves their mouse. Small detail, noticeable if you skip it.

One more thing — the rgba(139, 92, 246, 0.15) value above is a muted violet. You don't have to stick with white. Colored spotlights against dark cards feel more branded and intentional. Try amber on a dark slate background, or a cool cyan on near-black. If you want a full color palette to experiment with, the gradient generator is a fast way to preview color combinations without writing a line of CSS.

In practice, the shared-parent approach works best when cards are roughly the same size and tightly spaced. If you've got a masonry layout or wildly different card heights, go back to individual tracking — the math for off-screen gradient positioning gets messy otherwise.

Layering the Effect: Border Glow, Inner Shine, and Tilt

The cursor-following gradient is the base layer. You can stack a few more CSS tricks on top to push the effect further without adding JavaScript complexity.

Border glow on hover. Add a second ::after pseudo-element with a thicker, more visible gradient that traces the card edge. Keep it at 1px normally and animate to 2px on hover via CSS transitions. This gives the card a subtle lit-border feel separate from the interior spotlight.

.spotlight-card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  border: 1px solid transparent;
  background: radial-gradient(
    var(--spotlight-size) circle at var(--x) var(--y),
    rgba(255,255,255,0.25),
    transparent 60%
  ) border-box;
  -webkit-mask:
    linear-gradient(#fff 0 0) padding-box,
    linear-gradient(#fff 0 0);
  -webkit-mask-composite: destination-out;
  mask-composite: exclude;
  opacity: var(--spotlight-opacity);
  transition: opacity 0.3s ease;
  pointer-events: none;
}

This is the border-image trick — using background-clip: border-box with a mask to paint only the border area. It's a 2024-era technique that's now supported in all major browsers. The result is a glowing border that follows the cursor independently of the fill gradient, giving the card real dimensionality.

3D tilt. If you want the full premium card treatment, add a gentle rotateX / rotateY transform driven by the same cursor coordinates. Keep the rotation small — ±6deg max — or it stops feeling like physics and starts feeling like a bug. A separate useRef and requestAnimationFrame wrapper handles this cleanly without adding to the CSS custom property chain. That said, tilt is genuinely optional. The spotlight alone carries the interaction; tilt is dessert.

For even more depth effects and interactive surfaces, Empire UI's box shadow generator is worth having open in a side tab — tweaking elevation shadows while you're tuning the spotlight blend makes the final result feel more cohesive.

Accessibility, Touch, and Reduced Motion

Touch devices don't fire mousemove. They fire touchmove, and the coordinate math is different (touch.clientX from e.touches[0]). You've got two options: add a parallel onTouchMove handler with the same coordinate logic, or accept that touch users see the parked-gradient state (no effect) and move on. Honestly, the second option is usually fine — the spotlight is pure decoration, not a functional affordance.

For prefers-reduced-motion, you want to suppress any transition on the gradient opacity. The gradient still follows the cursor (that's just a repaint, not an animation), but the fade-in/out on enter/leave should be instant:

@media (prefers-reduced-motion: reduce) {
  .spotlight-card::before,
  .spotlight-card::after {
    transition: none;
  }
}

Keyboard navigation is the one thing you can't ignore. If your spotlight card is also a focusable element (a <button> or <a>), show the spotlight on :focus-visible too. Set --spotlight-opacity: 1 and park --x / --y at the center of the card (50%, 50%) when focus arrives. This makes the focus state obvious without needing a separate outline style, though you should probably keep a subtle ring as well for high-contrast mode users.

None of this is complex, but skipping it in 2026 is increasingly a red flag in code reviews. Build the accessible version the first time — it's maybe 20 extra lines.

FAQ

Does the spotlight card work with Tailwind CSS?

Yes. The gradient logic lives in inline styles or a tiny CSS class you write once; Tailwind handles everything else (border-radius, padding, background color). You're not fighting any Tailwind defaults here.

Will this cause performance problems in a large grid?

Not if you use the CSS custom property approach — DOM style mutations bypass React's reconciler entirely. The shared-parent pattern processes all cards in one event handler, which is fine at grid sizes up to 20–30 cards.

Can I use this with Next.js App Router?

Yes, just add 'use client' at the top of the component file since it uses useRef and mouse event handlers. Everything else works out of the box with no additional config.

How do I make the spotlight color match my brand palette?

Pass a custom spotlightColor prop as an rgba() string and keep the alpha below 0.2 — higher values look garish on most dark backgrounds. Cool-toned brands (blue, violet) tend to work better than warm ones for this effect.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Gradient Card Design in React: Mesh, Conic and Radial ApproachesNeumorphism Card in React: Soft UI with Correct Contrast RatiosSpotlight Mouse Tracking Effect in React: Radial Gradient CursorSpotlight Card Effect in React: Cursor-Tracking Glow on Hover