Cards

Tilt Card

A card that tilts in 3D perspective toward the cursor and springs back flat on leave.

Install

npx shadcn@latest add @paragon/tilt-card

tilt-card.tsx

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