A row of three dark feature cards sharing one light. The light follows the pointer across the whole row, so the border of a neighbouring card brightens as you approach it, and a dot grid appears only where the light falls. When nobody is touching it the light sweeps slowly across the row by itself. On phones the cards stack and the same sweep runs down the column.
import { useEffect, useRef } from "react";
const features = [
{
kind: "timeline",
title: "Rollbacks in one click",
body: "Every deploy keeps its full build. Step back to any of the last fifty without rebuilding a thing.",
stat: "1.8s",
statLabel: "median rollback",
},
{
kind: "bars",
title: "Cached at 214 edges",
body: "Pages are served from the city your visitor is in, and purged everywhere the moment you ship.",
stat: "38ms",
statLabel: "time to first byte",
},
{
kind: "logs",
title: "Logs that stream live",
body: "Tail every request as it happens, filter by route or status, and share a link to the exact line.",
stat: "0",
statLabel: "sampling, ever",
},
];
const bars = [34, 52, 41, 68, 47, 82, 58, 44, 72, 39, 61, 50];
const logs = [
{ code: "200", path: "GET /pricing", ms: "31ms", w: "w-24" },
{ code: "200", path: "GET /api/cart", ms: "44ms", w: "w-28" },
{ code: "304", path: "GET /fonts/inter.woff2", ms: "6ms", w: "w-36" },
{ code: "201", path: "POST /api/orders", ms: "88ms", w: "w-32" },
];
function Visual({ kind }: { kind: string }) {
if (kind === "timeline") {
return (
<div className="flex h-full flex-col justify-center gap-3">
<div className="relative flex items-center justify-between">
<span className="absolute inset-x-2 top-1/2 h-px -translate-y-1/2 bg-zinc-700" />
{["v41", "v42", "v43", "v44"].map((v, i) => (
<span key={v} className={`relative flex h-9 w-9 items-center justify-center rounded-full border text-[10px] font-semibold ${i === 2 ? "border-emerald-300 bg-emerald-300 text-zinc-950" : "border-zinc-700 bg-zinc-900 text-zinc-400"}`}>
{v}
</span>
))}
</div>
<div className="flex items-center justify-between text-[11px] text-zinc-500">
<span>Tue 09:12</span>
<span className="rounded-full bg-emerald-300/10 px-2 py-0.5 font-medium text-emerald-300">Live now: v43</span>
<span>Fri 17:40</span>
</div>
</div>
);
}
if (kind === "bars") {
return (
<div className="flex h-full items-end gap-1.5">
{bars.map((h, i) => (
<span key={i} className={`flex-1 rounded-t-sm ${i === 5 ? "bg-emerald-300" : "bg-zinc-700"}`} style={{ height: `${h}%` }} />
))}
</div>
);
}
return (
<ul className="flex h-full flex-col justify-center gap-2 font-mono text-[11px]">
{logs.map((l) => (
<li key={l.path} className="flex items-center gap-2 text-zinc-400">
<span className={l.code === "200" || l.code === "201" ? "text-emerald-300" : "text-zinc-500"}>{l.code}</span>
<span className="truncate">{l.path}</span>
<span className="ml-auto text-zinc-500">{l.ms}</span>
</li>
))}
</ul>
);
}
export default function SpotlightCardsWithPointerLight() {
const gridRef = useRef<HTMLDivElement>(null);
const bloomRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
const cards = Array.from(grid.querySelectorAll<HTMLElement>("[data-card]"));
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
let offsets = cards.map((c) => ({ l: c.offsetLeft, t: c.offsetTop }));
let raf = 0;
let visible = true;
let held = false; // true while a pointer or keyboard focus is steering the light
const start = performance.now();
let x = grid.offsetWidth / 2;
let y = grid.offsetHeight * 0.2;
let tx = x;
let ty = y;
const paint = () => {
if (bloomRef.current) bloomRef.current.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
cards.forEach((c, i) => {
c.style.setProperty("--x", `${(x - offsets[i].l).toFixed(1)}px`);
c.style.setProperty("--y", `${(y - offsets[i].t).toFixed(1)}px`);
});
};
const frame = (now: number) => {
raf = 0;
if (!held) {
// Idle sweep: a slow figure across the whole row.
const t = (now - start) / 1000;
tx = grid.offsetWidth * (0.5 + 0.47 * Math.sin(t * 0.55 - 1.2));
ty = grid.offsetHeight * (0.42 + 0.34 * Math.sin(t * 0.9));
}
x += (tx - x) * 0.12;
y += (ty - y) * 0.12;
paint();
schedule();
};
const schedule = () => {
if (!raf && visible && !document.hidden && !reduce.matches) raf = requestAnimationFrame(frame);
};
const aim = (nx: number, ny: number) => {
tx = nx;
ty = ny;
if (reduce.matches) {
x = nx;
y = ny;
paint();
}
};
const onMove = (e: PointerEvent) => {
const r = grid.getBoundingClientRect();
held = true;
aim(e.clientX - r.left, e.clientY - r.top);
};
const release = () => {
held = false;
};
const onFocus = (e: FocusEvent) => {
const card = (e.target as HTMLElement).closest<HTMLElement>("[data-card]");
if (!card) return;
held = true;
aim(card.offsetLeft + card.offsetWidth / 2, card.offsetTop + card.offsetHeight * 0.3);
};
const measure = () => {
offsets = cards.map((c) => ({ l: c.offsetLeft, t: c.offsetTop }));
paint();
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
schedule();
});
io.observe(grid);
const ro = new ResizeObserver(measure);
ro.observe(grid);
grid.addEventListener("pointermove", onMove);
grid.addEventListener("pointerleave", release);
grid.addEventListener("pointercancel", release);
grid.addEventListener("focusin", onFocus);
grid.addEventListener("focusout", release);
document.addEventListener("visibilitychange", schedule);
paint();
schedule();
return () => {
cancelAnimationFrame(raf);
io.disconnect();
ro.disconnect();
grid.removeEventListener("pointermove", onMove);
grid.removeEventListener("pointerleave", release);
grid.removeEventListener("pointercancel", release);
grid.removeEventListener("focusin", onFocus);
grid.removeEventListener("focusout", release);
document.removeEventListener("visibilitychange", schedule);
};
}, []);
return (
<section className="relative overflow-hidden bg-zinc-950 px-5 py-16 text-zinc-100 sm:px-6 sm:py-20">
<style>{`
@keyframes spotlight-cards-with-pointer-light-rise {
from { opacity: 0; transform: translateY(26px) scale(0.97); }
to { opacity: 1; transform: none; }
}
.spotlight-cards-with-pointer-light-rise {
animation: spotlight-cards-with-pointer-light-rise 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.spotlight-cards-with-pointer-light-rise { animation: none; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="max-w-2xl">
<p className="inline-flex items-center gap-2 rounded-full border border-zinc-800 px-3 py-1 text-xs font-medium text-zinc-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
Halyard Deploy
</p>
<h2 className="mt-5 text-3xl font-semibold tracking-tight text-white sm:text-5xl">Ship on Friday. Sleep on Friday.</h2>
<p className="mt-4 max-w-xl text-base text-zinc-400">The boring parts of releasing software, handled. Move the light across the cards to see what is underneath.</p>
</div>
<div ref={gridRef} className="relative mt-12 grid gap-4 md:grid-cols-3">
{/* A wide bloom behind the row, so the light also spills into the gaps. */}
<span aria-hidden="true" ref={bloomRef} className="pointer-events-none absolute left-0 top-0 h-0 w-0">
<span className="absolute -left-60 -top-60 h-[480px] w-[480px] rounded-full bg-emerald-400/20 blur-3xl" />
</span>
{features.map((f, i) => (
<article
key={f.title}
data-card
className="spotlight-cards-with-pointer-light-rise group relative rounded-3xl bg-zinc-800/70 p-px"
style={{ animationDelay: `${0.08 + i * 0.11}s`, ["--x" as string]: "50%", ["--y" as string]: "0px" }}
>
{/* The light on the border: only the 1px gap around the inner panel shows it. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-3xl"
style={{ background: "radial-gradient(380px circle at var(--x) var(--y), rgba(236,253,245,1), rgba(52,211,153,0.75) 30%, transparent 70%)" }}
/>
<div className="relative flex h-full flex-col overflow-hidden rounded-[23px] bg-zinc-950 p-6 sm:p-7">
{/* The soft pool of light inside the card. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{ background: "radial-gradient(460px circle at var(--x) var(--y), rgba(52,211,153,0.28), rgba(52,211,153,0.07) 45%, transparent 66%)" }}
/>
{/* A dot grid that only exists where the light falls. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "radial-gradient(rgba(209,250,229,0.7) 1px, transparent 1.2px)",
backgroundSize: "16px 16px",
maskImage: "radial-gradient(240px circle at var(--x) var(--y), black, transparent 72%)",
WebkitMaskImage: "radial-gradient(240px circle at var(--x) var(--y), black, transparent 72%)",
}}
/>
<div className="relative h-28 rounded-2xl border border-zinc-800 bg-zinc-900/60 px-4 py-3">
<Visual kind={f.kind} />
</div>
<h3 className="relative mt-6 text-lg font-semibold tracking-tight text-white">{f.title}</h3>
<p className="relative mt-2 text-sm leading-6 text-zinc-400">{f.body}</p>
<div className="relative mt-6 flex items-end justify-between border-t border-zinc-800/80 pt-5">
<p>
<span className="block text-2xl font-semibold tracking-tight text-white">{f.stat}</span>
<span className="text-xs text-zinc-500">{f.statLabel}</span>
</p>
<a
href="#"
aria-label={`Read more about: ${f.title}`}
className="inline-flex h-10 items-center gap-1.5 rounded-full border border-zinc-800 px-4 text-sm font-medium text-zinc-200 transition hover:border-emerald-300/60 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300"
>
Read more
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4 transition-transform group-hover:translate-x-0.5">
<path d="M4 10h12M11 5l5 5-5 5" />
</svg>
</a>
</div>
</div>
</article>
))}
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/spotlight-cards-with-pointer-light.json
Each card has two CSS variables, `--x` and `--y`, holding the light position in pixels relative to that card. A `radial-gradient(... at var(--x) var(--y))` paints the glow. One `pointermove` listener on the row updates the variables for all three cards, which is why the light crosses from one card to the next without a seam.
The card is a wrapper with one pixel of padding around an opaque inner panel. The gradient is painted on the wrapper, so the only part you can see is the one pixel gap. That gives a sharp line of light instead of a haze.
No. The position is written straight to the element style with `setProperty`, so React never re-renders. One animation frame loop eases the light toward its target and stops when the row is off screen or the tab is hidden.
There is no hover on touch, so the idle sweep does the work: the light drifts down the stacked cards on its own. Dragging a finger across a card also moves it.
No. This one uses only React, Tailwind classes and a small style block for the entrance keyframes.
Related: Three Column Feature Grid with Icons, Bento Grid with Six Feature Tiles, Dark Hero with Gradient Glow and Product Window.
More animated cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.