bottega

primitives

Spotlight Card

A card surface with a soft radial spotlight that follows the cursor in real time. A two-stop gradient (tight bright core + wide feathered halo) is composited over the card via mix-blend-mode: overlay, so content tints under the light instead of being covered. Tracking is CSS-only (--x/--y set on pointermove, no per-frame React) and the glow springs on/off gracefully. Optional cursor-following border glow; fully keyboard-focusable with a focus-visible ring that the glow never suppresses.

Spotlight Card

Move your cursor across the surface — a soft radial glow tracks the pointer.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/spotlight-card

Usage

Usagetsx
<SpotlightCard>
  {/* children */}
</SpotlightCard>

Props

PropTypeDefaultDescription
children*React.ReactNodeCard content rendered inside the spotlight surface.
classNamestringAdditional class names merged onto the root element.
spotlightColorstringCSS color for the spotlight core. Default: a token-derived primary tint via color-mix. Accepts any valid CSS color string.
spotlightSizenumber300Diameter of the spotlight in px at rest (default 300).
glowIntensityenumdefaultPreset opacity tier for the glow overlay (composited via mix-blend-mode: overlay).
borderGlowbooleanfalseWhen true, a faint rim glow traces the border toward the cursor (default false).
asReact.ElementTypePolymorphic root element (default "div"). Use "article"/"section" for semantics.
tabIndexnumber0Forwarded to root; defaults to 0 so the card is keyboard-focusable.

Source

spotlight-card.tsxtsx
"use client";

import { createElement, useCallback, useEffect, useRef, useState } from "react";
import type React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./spotlight-card.module.css";

const INTENSITY_TIERS = {
  subtle: 0.25,
  default: 0.45,
  strong: 0.65,
} as const;

export type SpotlightCardProps = {
  /** Card content rendered inside the spotlight surface. */
  children: React.ReactNode;
  /** Additional class names merged onto the root element. */
  className?: string;
  /**
   * CSS color for the spotlight core.
   * Default: a token-derived primary tint via color-mix.
   * Accepts any valid CSS color string.
   */
  spotlightColor?: string;
  /** Diameter of the spotlight in px at rest (default 300). */
  spotlightSize?: number;
  /** Preset opacity tier for the glow overlay (composited via mix-blend-mode: overlay). */
  glowIntensity?: keyof typeof INTENSITY_TIERS;
  /** When true, a faint rim glow traces the border toward the cursor (default false). */
  borderGlow?: boolean;
  /** Polymorphic root element (default "div"). Use "article"/"section" for semantics. */
  as?: React.ElementType;
  /** Forwarded to root; defaults to 0 so the card is keyboard-focusable. */
  tabIndex?: number;
} & Omit<React.HTMLAttributes<HTMLElement>, "color">;

export function SpotlightCard({
  children,
  className = "",
  spotlightColor,
  spotlightSize = 300,
  glowIntensity = "default",
  borderGlow = false,
  as,
  tabIndex = 0,
  style: consumerStyle,
  onPointerMove: consumerOnPointerMove,
  onPointerEnter: consumerOnPointerEnter,
  onPointerLeave: consumerOnPointerLeave,
  onPointerDown: consumerOnPointerDown,
  onPointerUp: consumerOnPointerUp,
  ...props
}: SpotlightCardProps) {
  const Root = (as ?? "div") as React.ElementType;
  const ref = useRef<HTMLElement>(null);

  // Mount-gate + null-safe reduced-motion check (contract snippet).
  // useReducedMotion() is null on SSR but a concrete boolean on first client render,
  // so we keep the server/first-client render byte-identical (static base) and only
  // attach pointer tracking after mount for non-reduced users.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const interactive = mounted && !reduce;

  const tier = INTENSITY_TIERS[glowIntensity];

  const setPoint = useCallback((xPct: string, yPct: string) => {
    const el = ref.current;
    if (!el) return;
    el.style.setProperty("--x", xPct);
    el.style.setProperty("--y", yPct);
  }, []);

  // Internal tracking runs first, then the consumer's handler — so passing a
  // pointer handler augments (never overrides) spotlight tracking/pressed state.
  const handlePointerMove = useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      const el = ref.current;
      if (el) {
        const rect = el.getBoundingClientRect();
        const x = ((e.clientX - rect.left) / rect.width) * 100;
        const y = ((e.clientY - rect.top) / rect.height) * 100;
        setPoint(`${x.toFixed(2)}%`, `${y.toFixed(2)}%`);
      }
      consumerOnPointerMove?.(e);
    },
    [setPoint, consumerOnPointerMove],
  );

  const handlePointerEnter = useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      ref.current?.style.setProperty("--glow-strength", String(tier));
      ref.current?.setAttribute("data-hovering", "true");
      consumerOnPointerEnter?.(e);
    },
    [tier, consumerOnPointerEnter],
  );

  const handlePointerLeave = useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      const el = ref.current;
      if (el) {
        el.style.setProperty("--glow-strength", "0");
        el.setAttribute("data-hovering", "false");
        el.setAttribute("data-pressed", "false");
      }
      consumerOnPointerLeave?.(e);
    },
    [consumerOnPointerLeave],
  );

  const handlePointerDown = useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      ref.current?.setAttribute("data-pressed", "true");
      consumerOnPointerDown?.(e);
    },
    [consumerOnPointerDown],
  );

  const handlePointerUp = useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      ref.current?.setAttribute("data-pressed", "false");
      consumerOnPointerUp?.(e);
    },
    [consumerOnPointerUp],
  );

  // CSS variables that always apply (drive sizing, focus + reduced-motion ambient).
  // Consumer style is merged in first; the component's required vars are applied
  // last so they can never be dropped, while consumer style is still honored.
  const rootStyle: React.CSSProperties & Record<string, string> = {
    ...consumerStyle,
    "--rest-size": `${spotlightSize}px`,
    "--spotlight-size": `${spotlightSize}px`,
    "--focus-strength": String(tier),
    "--ambient-strength": String(tier * 0.4),
  } as React.CSSProperties & Record<string, string>;
  if (spotlightColor) {
    rootStyle["--spotlight-color"] = spotlightColor;
  }

  const rootClassName = [styles.root, borderGlow && styles.borderGlow, className]
    .filter(Boolean)
    .join(" ");

  // Spotlight tracking is only attached for non-reduced, mounted users — reduced
  // motion renders a pure static surface. Consumer pointer handlers, however,
  // always fire (composed inside the internal handlers when interactive, passed
  // straight through when not) so they're never silently dropped.
  const pointerProps = interactive
    ? {
        onPointerMove: handlePointerMove,
        onPointerEnter: handlePointerEnter,
        onPointerLeave: handlePointerLeave,
        onPointerDown: handlePointerDown,
        onPointerUp: handlePointerUp,
      }
    : {
        onPointerMove: consumerOnPointerMove,
        onPointerEnter: consumerOnPointerEnter,
        onPointerLeave: consumerOnPointerLeave,
        onPointerDown: consumerOnPointerDown,
        onPointerUp: consumerOnPointerUp,
      };

  // Polymorphic root via createElement — avoids JSX widening `as` over every
  // intrinsic element (which collapses `children` to `never` / overflows the
  // union). Runtime is identical to <Root>…</Root>.
  return createElement(
    Root,
    {
      ref,
      tabIndex,
      ...props,
      className: rootClassName,
      style: rootStyle,
      ...pointerProps,
    },
    <span key="glow" aria-hidden="true" className={styles.glow} />,
    <div key="content" className={styles.content}>
      {children}
    </div>,
  );
}

Dependencies

  • motion
  • @bottega/tokens