A 3D-tilt card with a moving specular highlight and a subtle glare band that track the pointer.
npx shadcn@latest add @paragon/tilt-spotlight-card"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* TiltSpotlightCard — a 3D-tilt card that adds a moving specular highlight and
* a subtle diagonal glare band, both tracking the pointer. The tilt reacts on
* zero-bounce springs, the highlight is a radial bloom at the pointer, and the
* glare is a thin sheen that sweeps as the card rotates.
*
* Gated to `(hover: hover) and (pointer: fine)`; on touch, under
* `prefers-reduced-motion`, or with `static` it renders as a flat card. All
* motion values are created unconditionally at the top level (hooks never
* hide behind the gate) — the gate only decides whether they're wired to the
* element. Wrap any content.
*/
export interface TiltSpotlightCardProps
extends Omit<
React.ComponentProps<"div">,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Max tilt in degrees on each axis. */
maxTilt?: number;
/** Specular highlight intensity, 0–1. */
glare?: number;
/** Highlight color. */
color?: string;
/** Hover lift in px. */
lift?: number;
/** Renders a plain, non-tilting card. */
static?: boolean;
children: React.ReactNode;
}
export function TiltSpotlightCard({
maxTilt = 12,
glare = 0.6,
color = "#ffffff",
lift = 6,
static: isStatic = false,
className,
children,
...props
}: TiltSpotlightCardProps) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLDivElement>(null);
const [fine, setFine] = React.useState(false);
const px = useMotionValue(0.5); // 0..1
const py = useMotionValue(0.5);
const active = useMotionValue(0);
const spring = { stiffness: 260, damping: 26, mass: 0.6 };
const rx = useSpring(useTransform(py, [0, 1], [maxTilt, -maxTilt]), spring);
const ry = useSpring(useTransform(px, [0, 1], [-maxTilt, maxTilt]), spring);
const z = useSpring(useTransform(active, [0, 1], [0, lift]), spring);
// Specular bloom at the pointer.
const highlightBg = useTransform([px, py], ([vx, vy]) => {
return `radial-gradient(40% 40% at ${(vx as number) * 100}% ${(vy as number) * 100}%, ${color}, transparent 70%)`;
});
// Glare: a thin, feathered sheen band whose angle tracks the pointer. Kept
// narrow and low-opacity so it reads as light grazing the surface, never a
// colored wash.
const glareBg = useTransform([px, py], ([vx, vy]) => {
const angle = 120 + ((vx as number) - 0.5) * 60;
const a = glare * 0.32 * ((vy as number) * 0.5 + 0.5);
const peak = color;
return `linear-gradient(${angle}deg, transparent 42%, color-mix(in oklch, ${peak} ${Math.round(
a * 100,
)}%, transparent) 50%, transparent 58%)`;
});
const hlOpacity = useTransform(active, [0, 1], [0, glare]);
const glareOpacity = useTransform(active, [0, 1], [0, 1]);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches && !reduce);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, [reduce]);
const enabled = fine && !isStatic;
const onMove = (e: React.PointerEvent) => {
if (!enabled) return;
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
px.set((e.clientX - rect.left) / rect.width);
py.set((e.clientY - rect.top) / rect.height);
};
const onEnter = () => enabled && active.set(1);
const onLeave = () => {
active.set(0);
px.set(0.5);
py.set(0.5);
};
return (
<div style={{ perspective: 1000 }} className="inline-block">
<motion.div
ref={ref}
data-slot="tilt-spotlight-card"
onPointerMove={onMove}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
style={
enabled
? {
rotateX: rx,
rotateY: ry,
z,
transformStyle: "preserve-3d",
}
: undefined
}
className={cn(
"relative overflow-hidden rounded-xl bg-card text-card-foreground shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover",
className,
)}
{...props}
>
<div style={{ transform: "translateZ(0)" }}>{children}</div>
{enabled && (
<>
{/* specular highlight */}
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light"
style={{ opacity: hlOpacity, background: highlightBg }}
/>
{/* glare sweep — soft-light keeps the sheen neutral, never a wash */}
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light"
style={{ opacity: glareOpacity, background: glareBg }}
/>
</>
)}
</motion.div>
</div>
);
}