"use client";
import * as React from "react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";
export interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Maximum rotation in degrees on each axis. */
maxTilt?: number;
/** Scale while the pointer is over the card. */
hoverScale?: number;
/** CSS perspective distance (px). Lower = more dramatic. */
perspective?: number;
/** Show the moving glare highlight. */
glare?: boolean;
/** Peak opacity of the glare (0–1). */
glareOpacity?: number;
/** Class applied to the inner 3D surface (rounded corners, background…). */
surfaceClassName?: string;
}
const TiltContext = React.createContext<{ active: MotionValue<number> } | null>(null);
const SPRING = { stiffness: 180, damping: 18, mass: 0.5 };
export function TiltCard({
maxTilt = 12,
hoverScale = 1.03,
perspective = 900,
glare = true,
glareOpacity = 0.35,
className,
surfaceClassName,
children,
...props
}: TiltCardProps) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLDivElement>(null);
// Pointer position within the card, 0..1 (0.5 = centre).
const px = useMotionValue(0.5);
const py = useMotionValue(0.5);
const active = useMotionValue(0);
const rotateX = useSpring(useTransform(py, [0, 1], [maxTilt, -maxTilt]), SPRING);
const rotateY = useSpring(useTransform(px, [0, 1], [-maxTilt, maxTilt]), SPRING);
const scale = useSpring(useTransform(active, [0, 1], [1, hoverScale]), SPRING);
const glareAlpha = useSpring(useTransform(active, [0, 1], [0, glareOpacity]), SPRING);
const gx = useTransform(px, (v) => `${v * 100}%`);
const gy = useTransform(py, (v) => `${v * 100}%`);
const glareBg = useMotionTemplate`radial-gradient(circle at ${gx} ${gy}, rgb(255 255 255 / 0.9), rgb(255 255 255 / 0) 55%)`;
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
// Touch & pen: no tilt — scrolling stays smooth and nothing gets "stuck" tilted.
if (reduce || e.pointerType !== "mouse" || !ref.current) return;
const r = ref.current.getBoundingClientRect();
px.set(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)));
py.set(Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)));
active.set(1);
};
const reset = () => {
px.set(0.5);
py.set(0.5);
active.set(0);
};
return (
<TiltContext.Provider value={{ active }}>
<div
ref={ref}
onPointerMove={onPointerMove}
onPointerLeave={reset}
className={cn("relative", className)}
style={{ perspective }}
{...props}
>
<motion.div
style={{ rotateX, rotateY, scale, transformStyle: "preserve-3d" }}
className={cn("relative h-full w-full rounded-2xl will-change-transform", surfaceClassName)}
>
{children}
{glare && (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-overlay"
style={{ background: glareBg, opacity: glareAlpha, transform: "translateZ(1px)" }}
/>
)}
</motion.div>
</div>
</TiltContext.Provider>
);
}
export interface TiltCardLayerProps {
/** How far (px) this layer floats towards the viewer while the card is tilted. */
depth?: number;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
}
/**
* A child of `TiltCard` that pops forward in 3D while the card is hovered.
* Every wrapper between the card and a layer needs `transform-style: preserve-3d`
* (Tailwind: `[transform-style:preserve-3d]`), or nest layers directly.
*/
export function TiltCardLayer({ depth = 40, className, children, style }: TiltCardLayerProps) {
const ctx = React.useContext(TiltContext);
const fallback = useMotionValue(0);
const z = useSpring(useTransform(ctx?.active ?? fallback, [0, 1], [0, depth]), SPRING);
return (
<motion.div className={cn("relative", className)} style={{ ...style, z, transformStyle: "preserve-3d" }}>
{children}
</motion.div>
);
}