A card that tilts in 3D perspective toward the cursor and springs back flat on leave.
npx shadcn@latest add @paragon/tilt-card"use client";
import * as React from "react";
import {
motion,
useMotionTemplate,
useReducedMotion,
useSpring,
} from "motion/react";
import { cn } from "@/lib/utils";
export interface TiltCardProps extends React.ComponentProps<typeof motion.div> {
/** Maximum tilt in degrees at the card's edges. Keep restrained. */
maxTilt?: number;
/** Perspective distance in px — smaller is more dramatic. */
perspective?: number;
/** Renders a plain card with no tilt. */
static?: boolean;
}
const clamp = (v: number, min: number, max: number) =>
Math.min(max, Math.max(min, v));
/**
* A card that tilts in 3D perspective toward the cursor and springs back
* flat on pointer leave. Rotation values run through springs (bounce 0) and
* are composed into a single full transform string, so the effect stays on
* the compositor and never fights React re-renders. Tilt only engages on
* fine pointers with real hover — touch devices, `prefers-reduced-motion`,
* and the `static` prop all get a plain, perfectly usable card.
*/
export function TiltCard({
maxTilt = 6,
perspective = 900,
static: isStatic = false,
className,
style,
onPointerMove,
onPointerLeave,
children,
...props
}: TiltCardProps) {
const reducedMotion = useReducedMotion();
const [finePointer, setFinePointer] = React.useState(false);
React.useEffect(() => {
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
setFinePointer(query.matches);
const onChange = (event: MediaQueryListEvent) =>
setFinePointer(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
const tiltEnabled = finePointer && !reducedMotion && !isStatic;
const rotateX = useSpring(0, { duration: 350, bounce: 0 });
const rotateY = useSpring(0, { duration: 350, bounce: 0 });
const transform = useMotionTemplate`perspective(${perspective}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerMove?.(event);
if (!tiltEnabled) return;
const rect = event.currentTarget.getBoundingClientRect();
// Cursor position mapped to [-1, 1] from the card's center.
const px = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
const py = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
// Rotate the card's normal toward the cursor.
rotateX.set(-py * maxTilt);
rotateY.set(px * maxTilt);
};
const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerLeave?.(event);
rotateX.set(0);
rotateY.set(0);
};
return (
<motion.div
data-slot="tilt-card"
className={cn(
"relative rounded-xl bg-card text-card-foreground shadow-border will-change-transform",
className,
)}
style={{ ...style, transform }}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
{...props}
>
{children}
</motion.div>
);
}