Spotlight Cards
A grid of cards lit by one soft light that follows the cursor.
Customize
<SpotlightCards cards={cards} />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
// A grid of cards with one soft spotlight that follows the cursor across the whole grid.
// It lights each card's border and surface, and the card under the cursor tilts a little.
// Only the grid listens to the pointer. It writes the spotlight position onto every card as two
// CSS variables, and the card styles do the rest. No library needed.
export type SpotlightCard = {
/** The card's heading. */
title: string;
/** One or two sentences under the heading. */
body: string;
/** A small icon shown in a box above the heading, e.g. an inline SVG. */
icon?: ReactNode;
/** A picture across the top of the card. */
image?: string;
/** Alt text for the picture. Leave empty if it is only decoration. */
imageAlt?: string;
};
export type SpotlightCardsProps = {
/** The cards, in reading order. */
cards: SpotlightCard[];
/** How far the light reaches from the cursor, in pixels. */
spotlightSize?: number;
/** How bright the light is, from 0 (off) to 1 (strong). */
intensity?: number;
/** The most a card tilts toward the cursor, in degrees. 0 turns the tilt off. */
tilt?: number;
/** The colour of the light. Any CSS colour. */
color?: string;
/** How many columns on a wide screen. Narrow screens use fewer, see minCardWidth. */
columns?: number;
/** Space between the cards, in pixels. */
gap?: number;
/** A card never gets narrower than this, in pixels, so phones drop to fewer columns. */
minCardWidth?: number;
/** Extra classes for the grid. */
className?: string;
/** Extra classes for each card, e.g. a different radius or padding. */
cardClassName?: string;
};
// The ring is a 1px frame cut out of a filled box with a mask (the middle is subtracted)
const ringMask: CSSProperties = {
padding: 1,
mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
maskComposite: "exclude",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export function SpotlightCards({
cards,
spotlightSize = 380,
intensity = 0.6,
tilt = 6,
color = "#14b8a6",
columns = 3,
gap = 16,
minCardWidth = 160,
className = "",
cardClassName = "",
}: SpotlightCardsProps) {
const gridRef = useRef<HTMLUListElement>(null);
// Read when the pointer moves, so changing the tilt never rebuilds the listeners
const tiltRef = useRef(tilt);
tiltRef.current = tilt;
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const target = { x: 0, y: 0 };
const light = { x: 0, y: 0 };
let frame = 0;
let lastTime = 0;
let inside = false;
const items = () => Array.from(grid.children) as HTMLElement[];
// Give every card the light's position measured from its own top-left corner
const paintLight = () => {
for (const card of items()) {
card.style.setProperty("--x", `${light.x - card.offsetLeft}px`);
card.style.setProperty("--y", `${light.y - card.offsetTop}px`);
}
};
// Only the card under the pointer tilts. The pointer is turned into -1..1 across that card.
const paintTilt = () => {
const max = reduced ? 0 : tiltRef.current;
for (const card of items()) {
const px = target.x - card.offsetLeft;
const py = target.y - card.offsetTop;
const over = inside && px >= 0 && py >= 0 && px <= card.offsetWidth && py <= card.offsetHeight;
const across = over ? clamp((px / card.offsetWidth) * 2 - 1, -1, 1) : 0;
const down = over ? clamp((py / card.offsetHeight) * 2 - 1, -1, 1) : 0;
card.style.setProperty("--ry", `${across * max}deg`);
card.style.setProperty("--rx", `${-down * max}deg`);
card.style.setProperty("--lift", over && max > 0 ? "1.012" : "1");
}
};
// The light eases toward the pointer, so it glides instead of snapping
const step = (now: number) => {
const seconds = Math.min(0.05, (now - lastTime) / 1000);
lastTime = now;
const ease = 1 - Math.exp(-seconds * 18);
light.x += (target.x - light.x) * ease;
light.y += (target.y - light.y) * ease;
paintLight();
const close = Math.hypot(target.x - light.x, target.y - light.y) < 0.3;
frame = close ? 0 : requestAnimationFrame(step);
};
const follow = (event: PointerEvent, snap: boolean) => {
const rect = grid.getBoundingClientRect();
target.x = event.clientX - rect.left;
target.y = event.clientY - rect.top;
if (snap || reduced) {
light.x = target.x;
light.y = target.y;
paintLight();
} else if (!frame) {
lastTime = performance.now();
frame = requestAnimationFrame(step);
}
paintTilt();
};
const turnOn = (event: PointerEvent) => {
if (!event.isPrimary) return;
// A fresh entry starts the light under the pointer instead of sliding in from the last spot
const fresh = !inside;
inside = true;
grid.style.setProperty("--spot", "1");
follow(event, fresh);
};
const turnOff = () => {
inside = false;
grid.style.setProperty("--spot", "0");
paintTilt();
};
grid.addEventListener("pointermove", turnOn);
grid.addEventListener("pointerdown", turnOn);
grid.addEventListener("pointerleave", turnOff);
grid.addEventListener("pointercancel", turnOff);
return () => {
grid.removeEventListener("pointermove", turnOn);
grid.removeEventListener("pointerdown", turnOn);
grid.removeEventListener("pointerleave", turnOff);
grid.removeEventListener("pointercancel", turnOff);
if (frame) cancelAnimationFrame(frame);
};
}, []);
const glowAlpha = clamp(intensity, 0, 1);
const mix = (percent: number) => `color-mix(in srgb, ${color} ${Math.round(percent * 100)}%, transparent)`;
const light = (percent: number) => `radial-gradient(${spotlightSize}px circle at var(--x, -999px) var(--y, -999px), ${mix(percent)}, transparent)`;
const columnCount = Math.max(1, Math.round(columns));
// As many columns as fit, up to `columns`, and never narrower than minCardWidth
const cardWidth = `max(${minCardWidth}px, calc((100% - ${(columnCount - 1) * gap}px) / ${columnCount}))`;
return (
<ul
ref={gridRef}
className={`relative m-0 grid w-full list-none p-0 ${className}`}
style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${cardWidth}, 1fr))`, gap, touchAction: "pan-y", ["--spot" as string]: 0 }}
>
{cards.map((card, index) => (
<li
key={`${index}-${card.title}`}
className={`relative isolate flex flex-col overflow-hidden rounded-2xl ${cardClassName}`}
style={{
background: "color-mix(in srgb, currentColor 3.5%, transparent)",
transform: "perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--lift, 1))",
transition: "transform 380ms cubic-bezier(0.2, 0.8, 0.2, 1)",
willChange: "transform",
}}
>
{/* The always-on hairline border */}
<span aria-hidden className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]" style={{ ...ringMask, background: "color-mix(in srgb, currentColor 11%, transparent)" }} />
{card.image ? (
<div className="aspect-[5/2] w-full overflow-hidden">
<img src={card.image} alt={card.imageAlt ?? ""} className="block h-full w-full object-cover" draggable={false} loading="lazy" decoding="async" />
</div>
) : null}
<div className="flex flex-1 flex-col gap-3 p-5 sm:p-6">
{card.icon ? (
<span
aria-hidden
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl [&>svg]:h-5 [&>svg]:w-5"
style={{ background: "color-mix(in srgb, currentColor 6%, transparent)", boxShadow: "inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent)" }}
>
{card.icon}
</span>
) : null}
<h3 className="m-0 text-[17px] leading-tight font-medium tracking-[-0.01em]">{card.title}</h3>
<p className="m-0 text-[14px] leading-[1.55] opacity-65">{card.body}</p>
</div>
{/* The light on the surface, then the light on the border. Both fade with --spot. */}
<span aria-hidden className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]" style={{ background: light(glowAlpha * 0.34), opacity: "var(--spot)", transition: "opacity 450ms ease" }} />
<span aria-hidden className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]" style={{ ...ringMask, background: light(Math.min(1, glowAlpha * 1.35)), opacity: "var(--spot)", transition: "opacity 450ms ease" }} />
</li>
))}
</ul>
);
}
The component. Installs to components/dimaac/spotlight-cards.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.jsonIt adds components/dimaac/spotlight-cards.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/spotlight-cards.
By hand
# Nothing to install: it only needs React- It needs nothing but React 19.
- Copy the component file below into components/dimaac/spotlight-cards.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { SpotlightCards } from "@/components/dimaac/spotlight-cards" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
cards (required) | SpotlightCard[] | - | The cards, in reading order. |
spotlightSize | number | 380 | How far the light reaches from the cursor, in pixels. |
intensity | number | 0.6 | How bright the light is, from 0 (off) to 1 (strong). |
tilt | number | 6 | The most a card tilts toward the cursor, in degrees. 0 turns the tilt off. |
color | string | "#14b8a6" | The colour of the light. Any CSS colour. |
columns | number | 3 | How many columns on a wide screen. Narrow screens use fewer, see minCardWidth. |
gap | number | 16 | Space between the cards, in pixels. |
minCardWidth | number | 160 | A card never gets narrower than this, in pixels, so phones drop to fewer columns. |
className | string | "" | Extra classes for the grid. |
cardClassName | string | "" | Extra classes for each card, e.g. a different radius or padding. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the Spotlight Cards component (<SpotlightCards />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/spotlight-cards
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/spotlight-cards.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json
Then use it like this:
import { SpotlightCards } from "@/components/dimaac/spotlight-cards";
<SpotlightCards cards={cards} />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/spotlight-cards.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- spotlight-cards.md
- This page as Markdown: install, props and the full source
- spotlight-cards.json
- The shadcn registry item
About
A grid of cards where a single soft spotlight follows the pointer across the whole grid, lighting each card's border and surface, while the card under the cursor tilts gently toward it. One pointer listener on the grid writes the light's position onto the cards as CSS variables, so there are no per-card handlers and no animation library. It glides to the pointer, follows a finger on touch screens, works on light and dark backgrounds (the colour is yours), drops to fewer columns on phones, and keeps the tilt off when the visitor prefers reduced motion.
- Installs to
components/dimaac/spotlight-cards.tsx- Uses
- React only
- Tags
- cards, cursor, spotlight, tilt, css