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.