Skip to content

Spotlight Card

A radial glow follows the cursor across the surface.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/spotlight-card.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/spotlight-card/spotlight-card.tsx
    "use client";
    
    import type { ComponentProps, PointerEvent } from "react";
    import { motion, useMotionValue, useSpring } from "motion/react";
    import { cn } from "@/lib/utils";
    
    export interface SpotlightCardProps
      extends Omit<ComponentProps<"div">, "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Glow color across the surface. @default "color-mix(in oklab, var(--primary) 14%, transparent)" */
      color?: string;
      /** Glow color along the border. @default "color-mix(in oklab, var(--primary) 70%, transparent)" */
      borderColor?: string;
      /** Glow diameter in px. @default 380 */
      size?: number;
      /** Light up the border near the cursor too. @default true */
      border?: boolean;
    }
    
    const FADE = { stiffness: 260, damping: 30 };
    // Keeps only a 1px ring at the edge: the content box is cut out of the full box.
    const RING_MASK = {
      padding: 1,
      mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
      WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
      WebkitMaskComposite: "xor",
    } as const;
    
    export function SpotlightCard({
      color = "color-mix(in oklab, var(--primary) 14%, transparent)",
      borderColor = "color-mix(in oklab, var(--primary) 70%, transparent)",
      size = 380,
      border = true,
      className,
      children,
      onPointerMove,
      onPointerLeave,
      ...props
    }: SpotlightCardProps) {
      const x = useMotionValue(0);
      const y = useMotionValue(0);
      const presence = useMotionValue(0);
      const opacity = useSpring(presence, FADE);
    
      // The glow is a fixed-size circle moved with transforms; nothing repaints a gradient per frame.
      const glow = (background: string) => (
        <motion.span
          className="absolute block rounded-full"
          style={{ width: size, height: size, left: -size / 2, top: -size / 2, x, y, opacity, background: `radial-gradient(circle, ${background}, transparent 70%)` }}
        />
      );
    
      return (
        <div
          className={cn("relative overflow-hidden", className)}
          onPointerMove={(event: PointerEvent<HTMLDivElement>) => {
            onPointerMove?.(event);
            if (event.pointerType !== "mouse") return;
            const rect = event.currentTarget.getBoundingClientRect();
            x.set(event.clientX - rect.left);
            y.set(event.clientY - rect.top);
            presence.set(1);
          }}
          onPointerLeave={(event: PointerEvent<HTMLDivElement>) => {
            onPointerLeave?.(event);
            presence.set(0);
          }}
          {...props}
        >
          {/* Pointer-driven only (it never moves on its own), so reduced motion needs no special case. */}
          <span aria-hidden="true" className="pointer-events-none absolute inset-0">
            {glow(color)}
          </span>
          {border ? (
            <span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[inherit]" style={RING_MASK}>
              {glow(borderColor)}
            </span>
          ) : null}
          <div className="relative">{children}</div>
        </div>
      );
    }

Usage

import { SpotlightCard } from "@/components/ui/spotlight-card/spotlight-card";

export function Feature() {
  return (
    <SpotlightCard className="rounded-2xl border border-border bg-card p-5">
      <h3>Fast by default</h3>
    </SpotlightCard>
  );
}

API reference

PropTypeDefaultDescription
colorstring"color-mix(in oklab, var(--primary) 14%, transparent)"Glow color across the surface.
borderColorstring"color-mix(in oklab, var(--primary) 70%, transparent)"Glow color along the border.
sizenumber380Glow diameter in px.
borderbooleantrueLight up the border near the cursor too.

Also accepts every native <div> attribute, including ref and className.

  • The glow is a fixed-size radial circle moved with transforms; no gradient is repainted per frame.
  • The border light is the same glow behind a mask that keeps only a 1px ring, so the edge lights up near the cursor.
  • Use `color` and `borderColor` for dark cards (see the Dark example).

Accessibility

  • Purely decorative layers (aria-hidden); content and semantics are untouched.
  • The glow only follows a mouse. It's driven by the pointer and never moves on its own, so it stays on under reduced motion.