VeloxUI
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/react

Then 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>
  );
}