Swish UI
All componentsCursor Free

Spotlight cards

A soft light follows the pointer across a row of cards, and each card border glows where the light is closest.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useRef } from "react";
import styles from "./demo.module.css";

const CARDS = [
  { title: "Sync", text: "Calendars update in under a minute." },
  { title: "Pricing", text: "Gap nights get a smart discount." },
  { title: "Payouts", text: "Funds land the day after checkout." },
];

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);

  // One pointer position, written to every card in its own coordinates, so the light crosses gaps.
  const place = (clientX: number, clientY: number) => {
    root.current?.querySelectorAll<HTMLElement>("[data-card]").forEach((card) => {
      const rect = card.getBoundingClientRect();
      card.style.setProperty("--x", `${clientX - rect.left}px`);
      card.style.setProperty("--y", `${clientY - rect.top}px`);
    });
    root.current?.classList.add(styles.lit);
  };
  const move = (event: React.PointerEvent) => {
    if (event.pointerType !== "touch") place(event.clientX, event.clientY);
  };
  // Touch has no hover, so a tap moves the light and leaves it there.
  const tap = (event: React.PointerEvent) => {
    if (event.pointerType === "touch") place(event.clientX, event.clientY);
  };
  const leave = (event: React.PointerEvent) => {
    if (event.pointerType !== "touch") root.current?.classList.remove(styles.lit);
  };

  return (
    <div ref={root} className={`${styles.root} ${styles.lit}`} onPointerMove={move} onPointerDown={tap} onPointerLeave={leave}>
      <div className={styles.grid}>
        {CARDS.map((card) => (
          <article key={card.title} data-card className={styles.card}>
            <div className={styles.inner}>
              <span className={styles.icon} aria-hidden="true" />
              <h3 className={styles.title}>{card.title}</h3>
              <p className={styles.text}>{card.text}</p>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; container-type: inline-size; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; width: min(100%, 760px); }
.card {
  --x: 50%; --y: 50%;
  position: relative; padding: 1.5px; border-radius: 16px;
  background: radial-gradient(180px circle at var(--x) var(--y), var(--accent), var(--accent-soft) 35%, var(--line) 75%);
}
.inner {
  position: relative; height: 100%; display: grid; align-content: start; gap: 8px;
  padding: 18px 16px; border-radius: 14.5px; overflow: hidden; background: var(--white);
}
.inner::before {
  content: ""; position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity .4s var(--out);
  background: radial-gradient(240px circle at var(--x) var(--y), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
}
/* Before any pointer input the light rests on the middle card. */
.card:first-child { --x: 160%; }
.card:last-child { --x: -60%; }
.lit .inner::before { opacity: 1; }
.icon { position: relative; width: 32px; height: 32px; border-radius: 10px; background: var(--accent-pale); border: 1px solid var(--accent-soft); }
.icon::after { content: ""; position: absolute; inset: 9px; border-radius: 50%; border: 2px solid var(--accent-text); }
.title { position: relative; margin: 4px 0 0; font-size: 16px; font-weight: 650; letter-spacing: -0.01em; }
.text { position: relative; font-size: 13px; line-height: 1.45; color: var(--muted); }
@container (max-width: 460px) {
  .grid { gap: 8px; }
  .inner { padding: 14px 10px; gap: 6px; }
  .icon { width: 26px; height: 26px; }
  .icon::after { inset: 7px; }
  .title { font-size: 14px; }
  .text { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) { .inner::before { transition: none; } }

Use it for

  • Feature grids on a landing page
  • Pricing or plan cards that should feel tactile on hover

Avoid

  • Long lists, where the glow becomes noise
  • Low contrast themes, where the border glow is the only focus cue

How it works

One pointermove listener on the demo root writes the pointer position to every card as --x and --y, measured from that card's own corner. Each card's 1px padding shows a radial gradient through it, which reads as a lit border, and a pseudo element paints a fainter radial wash inside. Because every card gets the same pointer, the light continues across the gaps between cards.

Accessibility

The effect is decoration only; card content and focus styles are unchanged. Touch users move the light with a tap. Reduced motion drops the fade and keeps the light as a direct, non animated response to the pointer.

Found on