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
* a spring, 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 it renders flat. The
* card keeps `preserve-3d` and lifts on hover. Under reduced motion the tilt is
* suppressed (highlight/glare are non-motion decoration). 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;
children: React.ReactNode;
}
export function TiltSpotlightCard({
maxTilt = 12,
glare = 0.6,
color = "#ffffff",
lift = 6,
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);
// highlight position in %
const hx = useTransform(px, (v) => `${v * 100}%`);
const hy = useTransform(py, (v) => `${v * 100}%`);
// glare angle follows horizontal position
const glareBg = useTransform([px, py], ([vx, vy]) => {
const angle = 120 + ((vx as number) - 0.5) * 60;
const a = glare * 0.5 * ((vy as number) * 0.6 + 0.4);
return `linear-gradient(${angle}deg, transparent 30%, color-mix(in oklch, ${color} ${Math.round(
a * 100,
)}%, transparent) 50%, transparent 70%)`;
});
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 onMove = (e: React.PointerEvent) => {
if (!fine) 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 = () => fine && 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={
fine
? {
rotateX: rx,
rotateY: ry,
z,
transformStyle: "preserve-3d",
}
: undefined
}
className={cn(
"relative overflow-hidden rounded-2xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div style={{ transform: "translateZ(0)" }}>{children}</div>
{fine && (
<>
{/* specular highlight */}
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light"
style={{
opacity: hlOpacity,
background: useTransform(
[hx, hy],
([x, y]) =>
`radial-gradient(40% 40% at ${x} ${y}, ${color}, transparent 70%)`,
),
}}
/>
{/* glare sweep */}
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-overlay"
style={{ opacity: glareOpacity, background: glareBg }}
/>
</>
)}
</motion.div>
</div>
);
}