Spotlight Card
A soft light that follows your cursor across the cards.
Settings
Colors
A preview, kept in this browser for every component. The prompt keeps the role: your assistant finds the color in your project.
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.
Copy the prompt
Ask for it
For instance: Add this spotlight card effect to the hero of my home page.What it sends# 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(); }, }; } ```
Two files that become yours: the engine, written without React, and a thin component around it. No package to install. The component is marked use client, so it sits inside a server component as it is.
Add it with the shadcn CLI
In a project set up for shadcn/ui, one command writes both files. Without it, copy them by hand in the two steps below.npx shadcn@latest add https://foundry44.dev/r/spotlight-card.json
Or add the engine by hand
Save it aslib/spotlight-card-core.ts. It has no dependencies and does nothing on the server.And the component
Save it ascomponents/ui/spotlight-card.tsx. It creates the effect on mount, passes every prop change to it live, and cleans up on unmount.// Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT) // Generated by scripts/generate.mjs. Do not edit: change the core component instead. "use client"; import { useEffect, useRef, type ComponentPropsWithoutRef } from "react"; import { spotlightCard, type SpotlightCardOptions, type SpotlightCardInstance } from "@/lib/spotlight-card-core"; export interface SpotlightCardProps extends SpotlightCardOptions, Omit<ComponentPropsWithoutRef<"div">, keyof SpotlightCardOptions> {} export function SpotlightCard({ size, intensity, follow, items, style, ...props }: SpotlightCardProps) { const ref = useRef<HTMLElementTagNameMap["div"]>(null); const instance = useRef<SpotlightCardInstance | null>(null); useEffect(() => { if (!ref.current) return; instance.current = spotlightCard(ref.current); return () => { instance.current?.destroy(); instance.current = null; }; }, []); // Runs right after creation, then on every option change. useEffect(() => { instance.current?.update(definedOnly({ size, intensity, follow, items })); }, [size, intensity, follow, items]); return <div ref={ref} style={style} {...props} />; } function definedOnly<T extends object>(o: T): T { return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T; }
Or install the package
It is also on npm, in@foundry44/reactwith the other free components. Import it from there instead of@/components/ui.npm install @foundry44/react
import { SpotlightCard } from "@foundry44/react";
Use it
Every prop is optional: with none, you get the effect as the preview shows it.import { SpotlightCard } from "@/components/ui/spotlight-card"; export function Example() { return ( <SpotlightCard size={260} intensity={0.22} follow={0.2} items="" > {/* your content */} </SpotlightCard> ); }
PropsProp Default What it is size 260 Radius of the glow in px. intensity 0.22 Peak opacity of the glow, 0–1. follow 0.2 How closely the glow trails the cursor, 0–1. 1 sticks to it. items CSS selector for the cards inside the element (a grid). Empty lights the element itself. Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - Each lit card gets
overflow: hiddenso the glow stays inside its rounded corners: content meant to stick out must live in a wrapper. - A statically positioned card becomes
position: relativeto anchor the glow. - The glow is painted over the card's content: keep
intensitylow, or give the contentposition: relative; z-index: 1to lift it above. - Cards are found once with
items: cards added later needupdate({ 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).
- It follows
One file, readable, no dependencies and no build step. It sets itself up on every element marked data-f44="spotlight-card", in plain HTML, WordPress, Framer embeds or any framework.
Load the script
Download it, put it next to your page and load it once, anywhere in the page.<script src="spotlight-card.js" defer></script>
The file/*! * Foundry44 · Spotlight Card — https://foundry44.dev/components/spotlight-card * A soft radial glow follows the cursor across a card or grid of cards. * Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT) * * Usage: load this file, then add data-f44="spotlight-card" to any element. * Optional attributes (defaults shown): * data-f44-size="260" * data-f44-intensity="0.22" * data-f44-follow="0.2" * data-f44-items="" * Manual: const instance = Foundry44.spotlightCard(element, { size: 260, intensity: 0.22, follow: 0.2, items: "" }); * instance.destroy(); * Re-scan the page after adding elements dynamically: Foundry44.init(); * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ (function () { "use strict"; /** * 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. */ const spotlightCardDefaults = { 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; function spotlightCard(el, options = {}) { var _a, _b, _c; let opts = { ...spotlightCardDefaults, ...options }; const noop = { update() {}, destroy() {} }; if (typeof window === "undefined") return noop; // A light that follows a hovering pointer has nothing to follow on touch. if ((_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, "(pointer: coarse)").matches) return noop; const reduced = (_c = (_b = window.matchMedia) === null || _b === void 0 ? void 0 : _b.call(window, "(prefers-reduced-motion: reduce)").matches) !== null && _c !== void 0 ? _c : false; let cards = []; const mount = () => { const targets = opts.items ? Array.from(el.querySelectorAll(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 = () => { var _a; for (const card of cards) { (_a = card.rect) !== null && _a !== void 0 ? _a : (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) => { 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(); }, }; } function parse(value) { if (value === "true") return true; if (value === "false") return false; var n = Number(value); return value !== "" && !isNaN(n) ? n : value; } function init() { document.querySelectorAll('[data-f44="spotlight-card"]').forEach(function (el) { if (el.__f44) return; var options = {}; [ ["size", "data-f44-size"], ["intensity", "data-f44-intensity"], ["follow", "data-f44-follow"], ["items", "data-f44-items"], ].forEach(function (pair) { var value = el.getAttribute(pair[1]); if (value !== null) options[pair[0]] = parse(value); }); el.__f44 = spotlightCard(el, options); }); } var Foundry44 = (window.Foundry44 = window.Foundry44 || {}); Foundry44.spotlightCard = spotlightCard; Foundry44._inits = Foundry44._inits || []; Foundry44._inits.push(init); Foundry44.init = function () { Foundry44._inits.forEach(function (fn) { fn(); }); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); })();
Mark your element
Adddata-f44="spotlight-card". Each setting is an attribute on the same element, and an attribute always wins over the default.<div data-f44="spotlight-card" data-f44-size="260"> <!-- your content --> </div>
SettingsAttribute Default What it is data-f44-size 260 Radius of the glow in px. data-f44-intensity 0.22 Peak opacity of the glow, 0–1. data-f44-follow 0.2 How closely the glow trails the cursor, 0–1. 1 sticks to it. data-f44-items CSS selector for the cards inside the element (a grid). Empty lights the element itself. Drive it from JavaScript
When the element arrives after the page has loaded, or when the settings come from your code.// an element without data-f44, or the script would set it up twice const element = document.querySelector(".my-element"); const instance = Foundry44.spotlightCard(element, { size: 260, intensity: 0.22 }); instance.update({ size: 260 }); // any option, live instance.destroy(); // listeners and observers gone, inline styles restored // elements added after the page loaded Foundry44.init();
Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - Each lit card gets
overflow: hiddenso the glow stays inside its rounded corners: content meant to stick out must live in a wrapper. - A statically positioned card becomes
position: relativeto anchor the glow. - The glow is painted over the card's content: keep
intensitylow, or give the contentposition: relative; z-index: 1to lift it above. - Cards are found once with
items: cards added later needupdate({ 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).
- It follows
For a site builder: Webflow, WordPress, Shopify, Framer, or any site that accepts HTML. One block carries the whole script; paste it, then mark an element.
Paste the embed
Paste it where your builder takes custom HTML: an embed or code block on the page, or the site's footer code to use the effect on every page.Mark the element
Give the element the attributedata-f44with the valuespotlight-card. Most builders have a field for custom attributes; otherwise, add it to the element's HTML.SettingsEvery setting is an attribute too, on the same element. Leave one out and the default applies.Attribute Default What it is data-f44-size 260 Radius of the glow in px. data-f44-intensity 0.22 Peak opacity of the glow, 0–1. data-f44-follow 0.2 How closely the glow trails the cursor, 0–1. 1 sticks to it. data-f44-items CSS selector for the cards inside the element (a grid). Empty lights the element itself. Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - Each lit card gets
overflow: hiddenso the glow stays inside its rounded corners: content meant to stick out must live in a wrapper. - A statically positioned card becomes
position: relativeto anchor the glow. - The glow is painted over the card's content: keep
intensitylow, or give the contentposition: relative; z-index: 1to lift it above. - Cards are found once with
items: cards added later needupdate({ 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).
- It follows