1"use client";2
3import React, { createContext, useContext, useEffect, useState } from "react";4import {5 motion,6 useMotionTemplate,7 useMotionValue,8 useReducedMotion,9 useSpring,10 useTransform,11 type MotionValue,12} from "motion/react";13import { cn } from "@/lib/utils";14
15export type ThreeDTiltCardProps = {16 children: React.ReactNode;17 className?: string;18 19 intensity?: number;20 21 depth?: number;22 border?: boolean;23 24 glare?: boolean;25 26 lift?: boolean;27};28
29type TiltContext = { depth: number; active: MotionValue<number>; reduce: boolean };30const Ctx = createContext<TiltContext | null>(null);31
32const SPRING = { stiffness: 260, damping: 22, mass: 0.6 };33
34function useFinePointer() {35 const [fine, setFine] = useState(false);36 useEffect(() => {37 const mq = window.matchMedia("(hover: hover) and (pointer: fine)");38 const sync = () => setFine(mq.matches);39 sync();40 mq.addEventListener("change", sync);41 return () => mq.removeEventListener("change", sync);42 }, []);43 return fine;44}45
46export function ThreeDTiltCard({47 children,48 className,49 intensity = 0.4,50 depth = 40,51 border = true,52 glare = true,53 lift = true,54}: ThreeDTiltCardProps) {55 const reduce = !!useReducedMotion();56 const fine = useFinePointer();57 const enabled = fine && !reduce;58 const maxTilt = intensity * 25;59
60 61 const px = useMotionValue(0.5);62 const py = useMotionValue(0.5);63 const hover = useMotionValue(0);64 const sx = useSpring(px, SPRING);65 const sy = useSpring(py, SPRING);66 const active = useSpring(hover, { stiffness: 300, damping: 30 });67
68 const rotateY = useTransform(sx, [0, 1], [-maxTilt, maxTilt]);69 const rotateX = useTransform(sy, [0, 1], [maxTilt, -maxTilt]);70 const scale = useTransform(active, [0, 1], [1, lift ? 1.025 : 1]);71
72 const gx = useTransform(sx, (v) => `${v * 100}%`);73 const gy = useTransform(sy, (v) => `${v * 100}%`);74 const glareBg = useMotionTemplate`radial-gradient(circle at ${gx} ${gy}, rgba(255,255,255,0.32), rgba(255,255,255,0) 55%)`;75 const edgeBg = useMotionTemplate`radial-gradient(240px circle at ${gx} ${gy}, color-mix(in oklab, var(--color-ink) 35%, transparent), transparent 70%)`;76 const glareOpacity = useTransform(active, [0, 1], [0, 1]);77
78 79 const shadowX = useTransform(sx, [0, 1], [14, -14]);80 const shadowY = useTransform(sy, [0, 1], [14, -6]);81 const shadowBlur = useTransform(active, [0, 1], [18, 42]);82 const shadowAlpha = useTransform(active, [0, 1], [0.1, 0.24]);83 const boxShadow = useMotionTemplate`${shadowX}px ${shadowY}px ${shadowBlur}px -12px rgba(0,0,0,${shadowAlpha})`;84
85 const move = (e: React.PointerEvent<HTMLDivElement>) => {86 if (!enabled || e.pointerType !== "mouse") return;87 const r = e.currentTarget.getBoundingClientRect();88 px.set(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)));89 py.set(Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)));90 hover.set(1);91 };92 const leave = () => {93 px.set(0.5);94 py.set(0.5);95 hover.set(0);96 };97
98 return (99 <Ctx.Provider value={{ depth, active, reduce: !enabled }}>100 <div101 className={cn("relative", className)}102 style={{ perspective: 900 }}103 onPointerMove={move}104 onPointerLeave={leave}105 >106 <motion.div107 className={cn(108 "group/tilt relative h-full w-full rounded-[18px] bg-(--color-paper)",109 border && "shadow-[inset_0_0_0_1px_var(--color-rule)]",110 )}111 style={112 enabled113 ? { rotateX, rotateY, scale, boxShadow, transformStyle: "preserve-3d" }114 : { boxShadow: "0 12px 24px -16px rgba(0,0,0,0.2)" }115 }116 >117 {}118 {enabled && border ? (119 <motion.div120 aria-hidden121 className="pointer-events-none absolute -inset-px rounded-[19px]"122 style={{123 background: edgeBg,124 opacity: glareOpacity,125 padding: 1,126 WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",127 WebkitMaskComposite: "xor",128 maskComposite: "exclude",129 }}130 />131 ) : null}132 <div className="relative h-full w-full" style={{ transformStyle: "preserve-3d" }}>133 {children}134 </div>135 {enabled && glare ? (136 <motion.div137 aria-hidden138 className="pointer-events-none absolute inset-0 overflow-hidden rounded-[18px] mix-blend-soft-light"139 style={{ background: glareBg, opacity: glareOpacity, transform: "translateZ(1px)" }}140 />141 ) : null}142 </motion.div>143 </div>144 </Ctx.Provider>145 );146}147
148149150151152export function TiltLayer({153 children,154 depth,155 className,156}: {157 children: React.ReactNode;158 159 depth?: number;160 className?: string;161}) {162 const ctx = useContext(Ctx);163 const fallback = useMotionValue(0);164 const amount = depth ?? ctx?.depth ?? 40;165 const z = useTransform(ctx?.active ?? fallback, [0, 1], [0, amount]);166 if (!ctx || ctx.reduce) return <div className={className}>{children}</div>;167 return (168 <motion.div className={className} style={{ z, transformStyle: "preserve-3d" }}>169 {children}170 </motion.div>171 );172}