---
title: Spotlight Cards
description: A grid of cards lit by one soft light that follows the cursor.
category: Cards
tier: free
url: https://ui.dimaac.com/components/spotlight-cards
registry: https://ui.dimaac.com/r/spotlight-cards.json
---

# Spotlight Cards

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/spotlight-cards`

Dependencies:

None: it only needs React 19.

By hand:

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.

## Usage

```tsx
import { SpotlightCards } from "@/components/dimaac/spotlight-cards";

<SpotlightCards cards={cards} />
```

## 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. |

## Credits

- Photo: Mikiwa, Lummi (https://www.lummi.ai/photo/modern-building-corner-dr5dz), Lummi License
- Photo: Mariate, Lummi (https://www.lummi.ai/photo/minimalist-architectural-design-b8bbc), Lummi License
- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License

## Source

### spotlight-cards.tsx → `components/dimaac/spotlight-cards.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import type { ReactNode } from "react";
import { SpotlightCards, type SpotlightCard, type SpotlightCardsProps } from "@/components/dimaac/spotlight-cards";

// A product page for a made-up design tool. Photos: Lummi (Lummi License).

// Small line icons, drawn inline so the demo needs no icon package
const Icon = ({ children }: { children: ReactNode }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
    {children}
  </svg>
);

const cards: SpotlightCard[] = [
  {
    title: "Briefs that write themselves",
    body: "Drop in a call transcript and get a one-page brief, with scope, dates and open questions already pulled out.",
    icon: (
      <Icon>
        <path d="M6 3h9l4 4v14H6z" />
        <path d="M14 3v5h5M9 13h7M9 17h5" />
      </Icon>
    ),
  },
  {
    title: "Moodboards, kept honest",
    body: "Every image remembers where it came from and who owns it, so nothing unlicensed reaches the client.",
    image: "https://ui.dimaac.com/media/architecture/white-corner-16x9.jpg",
    imageAlt: "A white building corner with a teal window against a teal sky",
  },
  {
    title: "Review rounds in one thread",
    body: "Comments sit on the exact frame, and every change is checked against the last round.",
    icon: (
      <Icon>
        <path d="M4 5h16v11H9l-5 4z" />
        <path d="M8 9.5h8M8 12.5h5" />
      </Icon>
    ),
  },
  {
    title: "Handoff without the folder dance",
    body: "Files, fonts and notes leave as one tidy package, named the way the developer expects.",
    image: "https://ui.dimaac.com/media/architecture/concrete-balconies-3x2.jpg",
    imageAlt: "Rows of concrete balconies against a pale sky",
  },
  {
    title: "Invoices that follow the work",
    body: "Hours and milestones become an invoice the moment a stage is signed off. Nothing to remember.",
    icon: (
      <Icon>
        <rect x="3" y="6" width="18" height="12" rx="2" />
        <circle cx="12" cy="12" r="2.5" />
        <path d="M6.5 9.5v.01M17.5 14.5v.01" />
      </Icon>
    ),
  },
  {
    title: "An archive you can search",
    body: "Find the sketch from two summers ago by what was in it, not what someone called the file.",
    image: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg",
    imageAlt: "Yellow and beige walls under a clear blue sky",
  },
];

export default function Demo(props: Partial<SpotlightCardsProps>) {
  return (
    <div className="relative h-full w-full overflow-x-hidden overflow-y-auto bg-[#f4f3ef] text-[#17201f] dark:bg-[#080b0b] dark:text-[#e6eeec]">
      {/* A faint grid behind everything gives the light something to land on */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-[0.07]"
        style={{
          backgroundImage: "linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
          backgroundSize: "56px 56px",
          maskImage: "radial-gradient(ellipse at 50% 30%, #000 20%, transparent 75%)",
          WebkitMaskImage: "radial-gradient(ellipse at 50% 30%, #000 20%, transparent 75%)",
        }}
      />

      <div className="relative mx-auto flex min-h-full w-full max-w-[1120px] flex-col justify-center px-5 py-8 sm:px-8 sm:py-10">
        <header className="mb-6 flex max-w-[640px] flex-col gap-3 sm:mb-9 sm:gap-3">
          <h1 className="m-0 text-[clamp(30px,4.6vw,54px)] leading-[0.98] font-medium tracking-[-0.04em]">Everything a studio needs, lit as you look.</h1>
          <p className="m-0 max-w-[46ch] text-[15px] leading-[1.55] opacity-60">Six small tools that replace six open tabs. Move across them.</p>
        </header>

        <SpotlightCards cards={cards} {...props} />
      </div>
    </div>
  );
}
```

## Agent prompt

```text
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
```
