Skip to the content

Library/ 06 spotlight-cardCards

Spotlight Card

A soft light that follows your cursor across the cards.

Move your cursor over the preview

Settings

260
0.22
0.20
itemsSet in the markup

Colors

colorBrand

What it does

Spotlight Card is a card animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called spotlight or glow card.

A gentle glow drifts after the pointer and spills over every card it passes, so a whole grid seems lit by one lamp. It fades in on hover and away on leave. Touch screens skip it, and reduced motion keeps it steady.

Documentation

One copy gives an assistant everything it needs: the complete code, every option and every pitfall. Paste it in Claude, Cursor or ChatGPT and say where the effect goes; it adapts the integration to your stack, not the code.

  1. Copy the prompt

  2. Ask for it

    For instance: Add this spotlight card effect to the hero of my home page.
    What it sends
    Markdown
    # Foundry44 · Spotlight Card
    
    A soft radial glow follows the cursor across a card or grid of cards.
    
    Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.
    
    ## Markup it expects
    
    The element marked `data-f44="spotlight-card"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `spotlightCard()` or as props.
    
    ```html
    <div data-f44="spotlight-card" data-f44-items=".sp-card" data-f44-size="340">
      <article class="sp-card">
        <p>01</p>
        <h3>Survey</h3>
        <p>We walk the site twice, in rain and in sun, before we draw a line.</p>
      </article>
      <article class="sp-card">
        <p>02</p>
        <h3>Design</h3>
        <p>One plan, drawn by hand, with every plant named where it will grow.</p>
      </article>
      <article class="sp-card">
        <p>03</p>
        <h3>Planting</h3>
        <p>Our own team plants it, then comes back each season for three years.</p>
      </article>
    </div>
    ```
    
    ## Options
    
    | Option | Attribute | Type | Default | Description |
    |---|---|---|---|---|
    | `size` | `data-f44-size` | `number` | `260` | Radius of the glow in px. |
    | `intensity` | `data-f44-intensity` | `number` | `0.22` | Peak opacity of the glow, 0–1. |
    | `follow` | `data-f44-follow` | `number` | `0.2` | How closely the glow trails the cursor, 0–1. 1 sticks to it. |
    | `items` | `data-f44-items` | `string` | `` | CSS selector for the cards inside the element (a grid). Empty lights the element itself. |
    
    ## Setup
    
    - **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/spotlight-card/spotlight-card.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="spotlight-card"` element and reads the `data-f44-*` attributes above.
    - **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/spotlight-card.json` writes `lib/spotlight-card-core.ts` and `components/ui/spotlight-card.tsx`, then use `<SpotlightCard />` with the options as props.
    - **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { SpotlightCard } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { spotlightCard } from "@foundry44/core/spotlight-card"`.
    - **React / Next.js by hand**: save the code below as `lib/spotlight-card-core.ts` and import it from there (`@/lib/spotlight-card-core` where that alias exists). Don't name it `spotlight-card.ts` next to a `SpotlightCard.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `spotlightCard(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
    - **Any other stack**: create on mount with `spotlightCard(element, options)`, `update(options)` when they change, `destroy()` on unmount.
    
    Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `spotlightCard(element)` on each element.
    
    ## Pitfalls
    
    - Respects prefers-reduced-motion: keep that behaviour.
    - Each lit card gets `overflow: hidden` so the glow stays inside its rounded corners: content meant to stick out must live in a wrapper.
    - A statically positioned card becomes `position: relative` to anchor the glow.
    - The glow is painted over the card's content: keep `intensity` low, or give the content `position: relative; z-index: 1` to lift it above.
    - Cards are found once with `items`: cards added later need `update({ items })` with a new selector, or a new instance.
    - Tint the glow with `--f44-spotlight-card-color` (white by default; pick a dark or brand color on light cards).
    
    ## Code: `spotlight-card-core.ts`
    
    ```ts
    // Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
    /**
     * Foundry44 — spotlight-card
     * A soft radial glow trails the cursor across a card. Given a selector for
     * the cards of a grid, the pointer is tracked over the whole grid and every
     * card shows its share of the same light, so neighbours glow at their edges.
     * The glow fades in on enter and out on leave. Disabled on touch; with
     * reduced motion it sits under the cursor without trailing or fading.
     *
     * Self-contained: no imports, so this file can be copied as-is.
     */
    
    export interface SpotlightCardOptions {
      /** Radius of the glow in px. @range 60 600 10 */
      size?: number;
      /** Peak opacity of the glow, 0–1. @range 0 1 0.01 */
      intensity?: number;
      /** How closely the glow trails the cursor, 0–1. 1 sticks to it. @range 0.02 1 0.01 */
      follow?: number;
      /** CSS selector for the cards inside the element (a grid). Empty lights the element itself. */
      items?: string;
    }
    
    export interface SpotlightCardInstance {
      update(options: SpotlightCardOptions): void;
      destroy(): void;
    }
    
    export const spotlightCardDefaults: Required<SpotlightCardOptions> = {
      size: 260,
      intensity: 0.22,
      follow: 0.2,
      items: "",
    };
    
    const REST = 0.1;
    // Share of the remaining fade covered each frame: about 200ms at 60fps.
    const FADE = 0.2;
    
    interface LitCard {
      el: HTMLElement;
      glow: HTMLElement;
      rect: DOMRect | null;
      original: { position: string; overflow: string };
    }
    
    export function spotlightCard(el: HTMLElement, options: SpotlightCardOptions = {}): SpotlightCardInstance {
      let opts = { ...spotlightCardDefaults, ...options };
    
      const noop: SpotlightCardInstance = { update() {}, destroy() {} };
      if (typeof window === "undefined") return noop;
      // A light that follows a hovering pointer has nothing to follow on touch.
      if (window.matchMedia?.("(pointer: coarse)").matches) return noop;
      const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
    
      let cards: LitCard[] = [];
    
      const mount = () => {
        const targets = opts.items ? Array.from(el.querySelectorAll<HTMLElement>(opts.items)) : [el];
        cards = targets.map((card) => {
          const original = { position: card.style.position, overflow: card.style.overflow };
          if (getComputedStyle(card).position === "static") card.style.position = "relative";
          card.style.overflow = "hidden";
          const glow = document.createElement("span");
          glow.setAttribute("aria-hidden", "true");
          Object.assign(glow.style, {
            position: "absolute",
            left: "0",
            top: "0",
            borderRadius: "50%",
            pointerEvents: "none",
            background: "radial-gradient(circle closest-side, var(--f44-spotlight-card-color, #fff), transparent)",
            opacity: "0",
            willChange: "transform, opacity",
          });
          card.append(glow);
          return { el: card, glow, rect: null, original };
        });
      };
    
      const unmount = () => {
        for (const card of cards) {
          card.glow.remove();
          card.el.style.position = card.original.position;
          card.el.style.overflow = card.original.overflow;
        }
        cards = [];
      };
    
      // Cursor in viewport coordinates; each card converts it to its own space.
      const pos = { x: 0, y: 0, hover: 0 };
      const target = { x: 0, y: 0, hover: 0 };
      let frame = 0;
    
      // Sized only on mount and update: width and height must never change per frame.
      const resize = () => {
        for (const card of cards) {
          card.glow.style.width = `${opts.size * 2}px`;
          card.glow.style.height = `${opts.size * 2}px`;
        }
      };
    
      const render = () => {
        for (const card of cards) {
          card.rect ??= card.el.getBoundingClientRect();
          const x = pos.x - card.rect.left - opts.size;
          const y = pos.y - card.rect.top - opts.size;
          card.glow.style.transform = `translate3d(${x}px, ${y}px, 0)`;
          card.glow.style.opacity = String(opts.intensity * pos.hover);
        }
      };
    
      const tick = () => {
        const ease = reduced ? 1 : opts.follow;
        pos.x += (target.x - pos.x) * ease;
        pos.y += (target.y - pos.y) * ease;
        pos.hover += (target.hover - pos.hover) * (reduced ? 1 : FADE);
        const settled =
          Math.abs(target.x - pos.x) < REST &&
          Math.abs(target.y - pos.y) < REST &&
          Math.abs(target.hover - pos.hover) < REST / 100;
        if (settled) Object.assign(pos, target);
        render();
        frame = settled ? 0 : requestAnimationFrame(tick);
      };
    
      const start = () => {
        if (!frame) frame = requestAnimationFrame(tick);
      };
    
      const onMove = (e: PointerEvent) => {
        target.x = e.clientX;
        target.y = e.clientY;
        // Entering from rest: start at the cursor so the glow fades in place instead of sliding in.
        if (pos.hover === 0) {
          pos.x = e.clientX;
          pos.y = e.clientY;
        }
        target.hover = 1;
        start();
      };
    
      const onLeave = () => {
        target.hover = 0;
        start();
      };
    
      const forgetRects = () => {
        for (const card of cards) card.rect = null;
      };
    
      mount();
      resize();
      el.addEventListener("pointermove", onMove, { passive: true });
      el.addEventListener("pointerleave", onLeave);
      window.addEventListener("scroll", forgetRects, { passive: true });
      window.addEventListener("resize", forgetRects);
    
      return {
        update(next) {
          const itemsChanged = next.items !== undefined && next.items !== opts.items;
          opts = { ...opts, ...next };
          if (itemsChanged) {
            unmount();
            mount();
          }
          resize();
          render();
        },
        destroy() {
          el.removeEventListener("pointermove", onMove);
          el.removeEventListener("pointerleave", onLeave);
          window.removeEventListener("scroll", forgetRects);
          window.removeEventListener("resize", forgetRects);
          if (frame) cancelAnimationFrame(frame);
          frame = 0;
          unmount();
        },
      };
    }
    ```