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.json
Pointer · JSpointer
packet-field
ember-field
constellation
type-log
move across the tiles

SpotCardesc or double-click to exit full screendouble-tap to exit

primitives/spot-card.tsx
"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