1"use client";2
3import React, { useCallback, useEffect, useRef, useState } from "react";4import {5 motion,6 useMotionValue,7 useSpring,8 useTransform,9 useReducedMotion,10} from "motion/react";11import { cn } from "@/lib/utils";12
13export type ThreeDTiltCardProps = {14 children: React.ReactNode;15 className?: string;16 17 intensity?: number;18 19 depth?: number;20 border?: boolean;21};22
23function useFinePointer() {24 const [fine, setFine] = useState(false);25 useEffect(() => {26 const mq = window.matchMedia("(hover: hover) and (pointer: fine)");27 const sync = () => setFine(mq.matches);28 sync();29 mq.addEventListener("change", sync);30 return () => mq.removeEventListener("change", sync);31 }, []);32 return fine;33}34
35const SPRING = { stiffness: 260, damping: 28, mass: 0.45 };36
37export function ThreeDTiltCard({38 children,39 className,40 intensity = 0.4,41 depth = 60,42 border = true,43}: ThreeDTiltCardProps) {44 const reduce = useReducedMotion();45 const finePointer = useFinePointer();46 const frameRef = useRef<HTMLDivElement>(null);47 const rectRef = useRef<DOMRect | null>(null);48 const [hovering, setHovering] = useState(false);49 const active = !reduce && finePointer;50
51 const x = useMotionValue(0);52 const y = useMotionValue(0);53 const z = useMotionValue(0);54
55 const springX = useSpring(x, SPRING);56 const springY = useSpring(y, SPRING);57 const springZ = useSpring(z, SPRING);58
59 const maxTilt = intensity * 25;60
61 62 63 const transform = useTransform([springX, springY], ([latestX, latestY]) => {64 const rx = -(latestY as number) * maxTilt;65 const ry = (latestX as number) * maxTilt;66 return `rotateX(${rx}deg) rotateY(${ry}deg)`;67 });68
69 const childTransform = useTransform(70 [springX, springY, springZ],71 ([latestX, latestY, latestZ]) => {72 const px = -(latestX as number) * depth * 0.16;73 const py = -(latestY as number) * depth * 0.16;74 return `translate3d(${px}px, ${py}px, ${latestZ as number}px)`;75 },76 );77
78 const refreshRect = useCallback(() => {79 rectRef.current = frameRef.current?.getBoundingClientRect() ?? null;80 }, []);81
82 const resetTilt = useCallback(() => {83 x.set(0);84 y.set(0);85 z.set(0);86 rectRef.current = null;87 setHovering(false);88 }, [x, y, z]);89
90 const handlePointerMove = useCallback(91 (e: React.PointerEvent<HTMLDivElement>) => {92 if (!active || e.pointerType !== "mouse") return;93
94 95 refreshRect();96 const rect = rectRef.current;97 if (!rect || rect.width === 0 || rect.height === 0) return;98
99 100 const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;101 const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1;102
103 x.set(Math.max(-1, Math.min(1, nx)));104 y.set(Math.max(-1, Math.min(1, ny)));105 z.set(depth);106 },107 [active, depth, refreshRect, x, y, z],108 );109
110 const handlePointerEnter = useCallback(111 (e: React.PointerEvent<HTMLDivElement>) => {112 if (!active || e.pointerType !== "mouse") return;113 setHovering(true);114 refreshRect();115 },116 [active, refreshRect],117 );118
119 useEffect(() => {120 if (!active) resetTilt();121 }, [active, resetTilt]);122
123 return (124 <div125 ref={frameRef}126 onPointerEnter={handlePointerEnter}127 onPointerMove={handlePointerMove}128 onPointerLeave={resetTilt}129 className={cn(130 "relative perspective-distant",131 hovering && "will-change-transform",132 className,133 )}134 >135 <motion.div136 style={{137 transform: active ? transform : undefined,138 transformStyle: "preserve-3d",139 }}140 className={cn(141 "relative h-full w-full rounded-md bg-background",142 border && "border border-border",143 )}144 >145 <div146 style={{ transformStyle: "preserve-3d" }}147 className="relative z-10 h-full w-full"148 >149 <motion.div150 style={{151 transform: active ? childTransform : undefined,152 transformStyle: "preserve-3d",153 }}154 >155 {children}156 </motion.div>157 </div>158 </motion.div>159 </div>160 );161}