Web InnoventixFreeCode

Card Spotlight Hover Effect

Original · free

A card with a cursor-following radial spotlight.

byWeb InnoventixReact + Tailwind
hovercardspotlight
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/hover-card-spotlight.json
hover-card-spotlight.tsx
"use client";

import { type PointerEvent, useCallback, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";

type SpotlightCard = {
  eyebrow: string;
  title: string;
  body: string;
  meta: string;
  href: string;
};

const CARDS: SpotlightCard[] = [
  {
    eyebrow: "Edge runtime",
    title: "Ship globally in one deploy",
    body: "Push once and your functions run in 34 regions, milliseconds from every visitor. No config, no cold-start tax.",
    meta: "34 regions",
    href: "#edge",
  },
  {
    eyebrow: "Observability",
    title: "Trace every request end to end",
    body: "Structured logs, spans and flame graphs stitched together automatically. Find the slow span before your users do.",
    meta: "p99 < 40ms",
    href: "#traces",
  },
  {
    eyebrow: "Security",
    title: "Secrets that never touch disk",
    body: "Encrypted at rest with per-environment scoping and full audit trails. Rotate a key and every replica updates live.",
    meta: "SOC 2 II",
    href: "#secrets",
  },
];

export default function HoverCardSpotlight() {
  const reduce = useReducedMotion();

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-6 py-20 text-zinc-900 sm:py-28 dark:bg-zinc-950 dark:text-zinc-100">
      <style>{`
        @keyframes hcs-rise {
          from { opacity: 0; transform: translateY(16px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        .hcs-rise { animation: hcs-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .hcs-rise { animation: none; }
        }
      `}</style>

      {/* ambient backdrop */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-60 [background-image:radial-gradient(circle_at_1px_1px,theme(colors.zinc.300)_1px,transparent_0)] [background-size:22px_22px] dark:opacity-40 dark:[background-image:radial-gradient(circle_at_1px_1px,theme(colors.zinc.800)_1px,transparent_0)]"
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="mx-auto mb-14 max-w-2xl text-center">
          <p className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Platform primitives
          </p>
          <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            Move your cursor across the cards
          </h2>
          <p className="mt-4 text-pretty text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            A radial spotlight follows the pointer and reveals a fine border glow,
            focusable by keyboard and gentle when motion is reduced.
          </p>
        </header>

        <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
          {CARDS.map((card, i) => (
            <Spotlight key={card.title} card={card} index={i} reduce={!!reduce} />
          ))}
        </div>
      </div>
    </section>
  );
}

function Spotlight({
  card,
  index,
  reduce,
}: {
  card: SpotlightCard;
  index: number;
  reduce: boolean;
}) {
  const ref = useRef<HTMLAnchorElement>(null);
  const [active, setActive] = useState(false);

  const move = useCallback((clientX: number, clientY: number) => {
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    el.style.setProperty("--hcs-x", `${clientX - rect.left}px`);
    el.style.setProperty("--hcs-y", `${clientY - rect.top}px`);
  }, []);

  const onPointerMove = useCallback(
    (e: PointerEvent<HTMLAnchorElement>) => {
      if (reduce) return;
      move(e.clientX, e.clientY);
    },
    [move, reduce]
  );

  const centerSpot = useCallback(() => {
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    el.style.setProperty("--hcs-x", `${rect.width / 2}px`);
    el.style.setProperty("--hcs-y", `${rect.height * 0.4}px`);
  }, []);

  return (
    <a
      ref={ref}
      href={card.href}
      onPointerMove={onPointerMove}
      onPointerEnter={() => setActive(true)}
      onPointerLeave={() => setActive(false)}
      onFocus={() => {
        centerSpot();
        setActive(true);
      }}
      onBlur={() => setActive(false)}
      style={{ animationDelay: reduce ? undefined : `${index * 90}ms` }}
      className={[
        "hcs-rise group relative block overflow-hidden rounded-2xl p-px",
        "outline-none transition-transform duration-300 ease-out",
        "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-950",
        reduce ? "" : "hover:-translate-y-1",
      ].join(" ")}
    >
      {/* border glow layer — follows cursor */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 rounded-2xl bg-zinc-200 transition-opacity duration-300 dark:bg-zinc-800"
        style={{
          opacity: active ? 1 : 0,
          background:
            "radial-gradient(220px circle at var(--hcs-x, 50%) var(--hcs-y, 50%), rgb(99 102 241 / 0.55), transparent 65%)",
        }}
      />

      {/* card face */}
      <div className="relative h-full rounded-[15px] bg-white p-6 dark:bg-zinc-900">
        {/* inner spotlight wash */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[15px] transition-opacity duration-300"
          style={{
            opacity: active ? 1 : 0,
            background:
              "radial-gradient(260px circle at var(--hcs-x, 50%) var(--hcs-y, 50%), rgb(99 102 241 / 0.12), transparent 60%)",
          }}
        />

        <div className="relative flex h-full flex-col">
          <div className="mb-5 flex items-center justify-between">
            <span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/25">
              {card.eyebrow}
            </span>
            <span className="font-mono text-xs tabular-nums text-zinc-400 dark:text-zinc-500">
              {card.meta}
            </span>
          </div>

          <h3 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
            {card.title}
          </h3>
          <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
            {card.body}
          </p>

          <span className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-indigo-600 dark:text-indigo-400">
            Learn more
            <svg
              viewBox="0 0 16 16"
              fill="none"
              aria-hidden
              className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-1"
            >
              <path
                d="M3 8h9M8.5 4l4 4-4 4"
                stroke="currentColor"
                strokeWidth="1.6"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </span>
        </div>
      </div>
    </a>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →