Skip to content
← Components

Spotlight Card

Source

spotlight-card.tsx
"use client";

import { useRef, type CSSProperties, type PointerEvent, type ReactNode } from "react";

type SpotlightCardProps = {
  children: ReactNode;
  className?: string;
  style?: CSSProperties;
};

/**
 * A surface whose border and fill pick up a soft light that follows the
 * pointer. Position is written to CSS variables on the element directly,
 * so moving the mouse never triggers a React re-render.
 */
export function SpotlightCard({ children, className = "", style }: SpotlightCardProps) {
  const ref = useRef<HTMLDivElement>(null);

  function handleMove(event: PointerEvent<HTMLDivElement>) {
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    // Account for any ancestor scale/zoom so the light sits under the pointer.
    const scale = rect.width / el.offsetWidth || 1;
    el.style.setProperty("--x", `${(event.clientX - rect.left) / scale}px`);
    el.style.setProperty("--y", `${(event.clientY - rect.top) / scale}px`);
  }

  return (
    <div
      ref={ref}
      onPointerMove={handleMove}
      style={style}
      className={`group/spot relative isolate overflow-hidden rounded-xl bg-line p-px shadow-sm [--x:50%] [--y:-40%] ${className}`}
    >
      {/* Border light: sits under a 1px inset surface, so only the edge glows. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-(--duration-exit) ease-out group-hover/spot:opacity-100 group-hover/spot:duration-(--duration-enter)"
        style={{
          background:
            "radial-gradient(240px circle at var(--x) var(--y), var(--accent-strong), transparent 70%)",
        }}
      />
      <div className="relative h-full rounded-[11px] bg-surface">
        {/* Fill light */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[11px] opacity-0 transition-opacity duration-500 group-hover/spot:opacity-100"
          style={{
            background:
              "radial-gradient(360px circle at var(--x) var(--y), color-mix(in oklab, var(--accent) 16%, transparent), transparent 60%)",
          }}
        />
        <div className="relative">{children}</div>
      </div>
    </div>
  );
}

const items = [
  {
    title: "Edge functions",
    body: "Run logic close to every user, with cold starts measured in single milliseconds.",
    stat: "12ms",
    label: "p50 latency",
  },
  {
    title: "Instant rollbacks",
    body: "Every deploy is immutable. Step back to any previous build in one click.",
    stat: "0.4s",
    label: "to restore",
  },
];

export default function Demo() {
  return (
    // Sized to its container, not the viewport: narrow stages keep both cards
    // side by side (so the light still crosses them) and drop the body copy.
    <div className="@container w-full max-w-xl">
      <div className="grid grid-cols-2 gap-3">
        {items.map((item, i) => (
          <SpotlightCard key={item.title} className="animate-enter" style={{ animationDelay: `calc(${i} * var(--stagger))` }}>
            <article className="flex h-full flex-col gap-4 p-4 @md:gap-6 @md:p-5">
              <div className="flex items-center gap-2">
                <span aria-hidden className="size-1.5 shrink-0 rounded-full bg-accent" />
                <h3 className="text-body font-medium text-ink">{item.title}</h3>
              </div>
              <p className="hidden text-body text-muted @md:block">{item.body}</p>
              <p className="mt-auto flex flex-wrap items-baseline gap-x-2 border-t border-line pt-4">
                <span className="font-mono text-title tracking-tight tabular-nums text-ink">{item.stat}</span>
                <span className="text-meta text-muted">{item.label}</span>
              </p>
            </article>
          </SpotlightCard>
        ))}
      </div>
    </div>
  );
}