Cards

Spotlight Card

A card with a soft radial glow that tracks the cursor across its surface on hover-capable devices.

Install

npx shadcn@latest add @paragon/spotlight-card

spotlight-card.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface SpotlightCardProps extends React.ComponentProps<"div"> {
  /** Diameter of the glow in px. */
  size?: number;
  /** Glow color. Defaults to a theme-aware wash of the primary color. */
  color?: string;
  /** Renders a plain card with no cursor-tracking glow. */
  static?: boolean;
}

/**
 * A card with a soft radial glow that tracks the cursor across its surface.
 *
 * The pointer position is written as CSS custom properties directly on the
 * overlay element — never on the parent, which would recalculate styles for
 * every descendant on each move. Tracking therefore costs one gradient
 * repaint and zero React renders. The overlay is decorative
 * (`aria-hidden`, `pointer-events-none`) and only becomes visible on
 * hover-capable fine-pointer devices; touch gets a plain card.
 */
export function SpotlightCard({
  size = 360,
  color = "color-mix(in oklab, var(--primary) 8%, transparent)",
  static: isStatic = false,
  className,
  children,
  onPointerMove,
  ...props
}: SpotlightCardProps) {
  const glowRef = React.useRef<HTMLDivElement>(null);

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event);
    const glow = glowRef.current;
    if (!glow || event.pointerType === "touch") return;
    const rect = event.currentTarget.getBoundingClientRect();
    glow.style.setProperty("--spotlight-x", `${event.clientX - rect.left}px`);
    glow.style.setProperty("--spotlight-y", `${event.clientY - rect.top}px`);
  };

  return (
    <div
      data-slot="spotlight-card"
      className={cn(
        "group/spotlight relative overflow-hidden rounded-xl bg-card p-6 text-card-foreground shadow-border",
        className,
      )}
      onPointerMove={isStatic ? onPointerMove : handlePointerMove}
      {...props}
    >
      {!isStatic && (
        <div
          ref={glowRef}
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-(--duration-base) ease-out [@media(hover:hover)_and_(pointer:fine)]:group-hover/spotlight:opacity-100"
          style={{
            background: `radial-gradient(${size}px circle at var(--spotlight-x, 50%) var(--spotlight-y, 40%), ${color}, transparent 70%)`,
          }}
        />
      )}
      {children}
    </div>
  );
}