← Components

Spotlight Cards

A grid of cards lit by one soft light that follows the cursor.

Cards · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

380 px
0.60
6.0 deg
#14b8a6
3
Your code
<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.json

It 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
  1. It needs nothing but React 19.
  2. Copy the component file below into components/dimaac/spotlight-cards.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { SpotlightCards } from "@/components/dimaac/spotlight-cards" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
cards (required)SpotlightCard[]-The cards, in reading order.
spotlightSizenumber380How far the light reaches from the cursor, in pixels.
intensitynumber0.6How bright the light is, from 0 (off) to 1 (strong).
tiltnumber6The most a card tilts toward the cursor, in degrees. 0 turns the tilt off.
colorstring"#14b8a6"The colour of the light. Any CSS colour.
columnsnumber3How many columns on a wide screen. Narrow screens use fewer, see minCardWidth.
gapnumber16Space between the cards, in pixels.
minCardWidthnumber160A card never gets narrower than this, in pixels, so phones drop to fewer columns.
classNamestring""Extra classes for the grid.
cardClassNamestring""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.

Prompt
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.txt

Files 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

Photo by Mikiwa, Mariate and rena on Lummi (Lummi License).