Spotlight Card
Move your cursor across the surface — a soft radial glow tracks the pointer.
primitives
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.
Move your cursor across the surface — a soft radial glow tracks the pointer.
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/spotlight-card<SpotlightCard>
{/* children */}
</SpotlightCard>| Prop | Type | Default | Description |
|---|---|---|---|
| children* | React.ReactNode | — | Card content rendered inside the spotlight surface. |
| className | string | Additional class names merged onto the root element. | |
| spotlightColor | string | — | CSS color for the spotlight core. Default: a token-derived primary tint via color-mix. Accepts any valid CSS color string. |
| spotlightSize | number | 300 | Diameter of the spotlight in px at rest (default 300). |
| glowIntensity | enum | default | Preset opacity tier for the glow overlay (composited via mix-blend-mode: overlay). |
| borderGlow | boolean | false | When true, a faint rim glow traces the border toward the cursor (default false). |
| as | React.ElementType | — | Polymorphic root element (default "div"). Use "article"/"section" for semantics. |
| tabIndex | number | 0 | Forwarded to root; defaults to 0 so the card is keyboard-focusable. |
"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>,
);
}