SpotCard
A spotlight that follows the pointer across a card. Listens on pointer rather than mouse, so a stylus or touch drag lights it too, and resets on leave.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/spot-card.jsonPointer · JSpointer
packet-field
ember-field
constellation
type-log
move across the tiles"use client";
import * as React from "react";
import { useReducedMotion, useTokenColorExpr } from "@pouriahlabs/wisp-ui";
/**
* SpotCard — a spotlight that follows the pointer across a card.
*
* Measure-then-draw at its smallest: the glow's position is the pointer offset
* inside *this* card's box, written to a custom property, so the gradient is
* pure CSS and the JS never touches layout.
*
* Two things it fixes over the usual implementation. It listens on `pointer`
* rather than `mouse`, so a stylus or touch drag lights it too; and it resets
* on leave, instead of stranding the glow wherever the pointer happened to
* exit — which reads as a stuck highlight on the next hover.
*/
export interface SpotCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Diameter of the spotlight, px. */
size?: number;
/** Token the glow is drawn from. */
token?: string;
/** Peak opacity of the glow. */
intensity?: number;
/** Fade in/out duration, ms. */
duration?: number;
}
export const SpotCard = React.forwardRef<HTMLDivElement, SpotCardProps>(function SpotCard(
{
size = 260,
token = "--wisp",
intensity = 0.16,
duration = 420,
className,
style,
children,
...props
},
forwardedRef,
) {
const ref = React.useRef<HTMLDivElement | null>(null);
const still = useReducedMotion();
const color = useTokenColorExpr(token);
// Merge the caller's ref with the internal one the pointer tracking writes to.
const setRef = React.useCallback(
(node: HTMLDivElement | null) => {
ref.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
// The card's box only moves on scroll or resize, not on every pointer step —
// so it is measured once on enter and refreshed on those events, rather than
// forcing a layout read (`getBoundingClientRect`) on each `pointermove`.
const rect = React.useRef<DOMRect | null>(null);
const measure = React.useCallback(() => {
if (ref.current) rect.current = ref.current.getBoundingClientRect();
}, []);
const track = (event: React.PointerEvent<HTMLDivElement>) => {
const card = ref.current;
const box = rect.current;
if (!card || !box) return;
card.style.setProperty("--wisp-spot-x", `${event.clientX - box.left}px`);
card.style.setProperty("--wisp-spot-y", `${event.clientY - box.top}px`);
};
const enter = React.useCallback(() => {
measure();
// Capture phase catches a scrolling ancestor, not just the window.
window.addEventListener("scroll", measure, { passive: true, capture: true });
window.addEventListener("resize", measure);
}, [measure]);
// Park the glow back at centre so the next hover starts from the pointer
// rather than from wherever the last one left, and stop tracking the box.
const reset = React.useCallback(() => {
window.removeEventListener("scroll", measure, { capture: true });
window.removeEventListener("resize", measure);
rect.current = null;
const card = ref.current;
if (!card) return;
card.style.removeProperty("--wisp-spot-x");
card.style.removeProperty("--wisp-spot-y");
}, [measure]);
// Drop the scroll/resize listeners if the card unmounts mid-hover.
React.useEffect(() => reset, [reset]);
return (
<div
ref={setRef}
onPointerEnter={still ? undefined : enter}
onPointerMove={still ? undefined : track}
onPointerLeave={reset}
className={["wisp-spot group/spot relative overflow-hidden", className]
.filter(Boolean)
.join(" ")}
style={
{
"--wisp-spot-size": `${size}px`,
"--wisp-spot-color": color,
"--wisp-spot-opacity": intensity,
"--wisp-spot-duration": `${still ? 0 : duration}ms`,
...style,
} as React.CSSProperties
}
{...props}
>
<span aria-hidden className="wisp-spot-glow" />
{children}
</div>
);
});
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React