interaction·free
Tilt Card
3D tilt on hover with a moving glare highlight.
Hover me
Perspective tilt that follows the cursor, with a glare pass.
Stronger, no glare
Tune max angle, scale, and glare per instance.
Installation
terminal
pnpm add gsap @gsap/reactThen copy the component below into src/components/motion/tilt-card.tsx. It expects @/lib/gsap (plugin registration) and @/lib/utils (cn).
Source
src/components/motion/tilt-card.tsx
"use client";
import { useRef, type HTMLAttributes, type ReactNode } from "react";
import { gsap, useGSAP } from "@/lib/gsap";
import { cn } from "@/lib/utils";
export interface TiltCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
/** Max tilt in degrees. */
max?: number;
glare?: boolean;
scale?: number;
}
export function TiltCard({ children, className, max = 12, glare = true, scale = 1.02, ...props }: TiltCardProps) {
const ref = useRef<HTMLDivElement>(null);
const glareRef = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const el = ref.current;
if (!el) return;
const rx = gsap.quickTo(el, "rotationX", { duration: 0.5, ease: "power3.out" });
const ry = gsap.quickTo(el, "rotationY", { duration: 0.5, ease: "power3.out" });
const move = (e: MouseEvent) => {
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
rx(-py * max * 2);
ry(px * max * 2);
if (glareRef.current) {
gsap.to(glareRef.current, {
opacity: 0.35,
background: `radial-gradient(circle at ${(px + 0.5) * 100}% ${(py + 0.5) * 100}%, rgba(255,255,255,.55), transparent 55%)`,
duration: 0.3,
});
}
};
const enter = () => gsap.to(el, { scale, duration: 0.4, ease: "power3.out" });
const leave = () => {
rx(0);
ry(0);
gsap.to(el, { scale: 1, duration: 0.6, ease: "power3.out" });
if (glareRef.current) gsap.to(glareRef.current, { opacity: 0, duration: 0.4 });
};
el.addEventListener("mousemove", move);
el.addEventListener("mouseenter", enter);
el.addEventListener("mouseleave", leave);
return () => {
el.removeEventListener("mousemove", move);
el.removeEventListener("mouseenter", enter);
el.removeEventListener("mouseleave", leave);
};
},
{ scope: ref, dependencies: [max, glare, scale] }
);
return (
<div style={{ perspective: 1000 }}>
<div
ref={ref}
className={cn("relative overflow-hidden rounded-2xl border border-border bg-card will-change-transform [transform-style:preserve-3d]", className)}
{...props}
>
{children}
{glare && <div ref={glareRef} className="pointer-events-none absolute inset-0 opacity-0" />}
</div>
</div>
);
}