"use client";
import { useCallback, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface TiltProps {
children?: ReactNode;
className?: string;
maxTilt?: number;
perspective?: number;
glare?: number;
glareSize?: number;
hoverScale?: number;
smoothing?: number;
glareColor?: string;
reducedMotion?: boolean;
}
const TILT_CSS = `
@property --sg-tilt-gx { syntax: "<length>"; inherits: true; initial-value: 50%; }
@property --sg-tilt-gy { syntax: "<length>"; inherits: true; initial-value: 50%; }
`;
const TILT_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
export default function Tilt({
children,
className,
maxTilt = 10,
perspective = 900,
glare = 0.35,
glareSize = 0.9,
hoverScale = 1.03,
smoothing = 0.28,
glareColor = "#c4b5fd",
reducedMotion = false,
}: TiltProps) {
const rootRef = useRef<HTMLDivElement>(null);
const apply = useCallback(
(clientX: number, clientY: number, active: boolean) => {
const el = rootRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const nx = (clientX - rect.left) / rect.width - 0.5;
const ny = (clientY - rect.top) / rect.height - 0.5;
el.style.setProperty("--sg-tilt-rx", `${(-ny * 2 * maxTilt).toFixed(3)}deg`);
el.style.setProperty("--sg-tilt-ry", `${(nx * 2 * maxTilt).toFixed(3)}deg`);
el.style.setProperty("--sg-tilt-gx", `${((nx + 0.5) * 100).toFixed(2)}%`);
el.style.setProperty("--sg-tilt-gy", `${((ny + 0.5) * 100).toFixed(2)}%`);
el.style.setProperty("--sg-tilt-scale", active ? `${hoverScale}` : "1");
el.style.setProperty("--sg-tilt-glare", active ? `${glare}` : "0");
},
[maxTilt, hoverScale, glare],
);
const rest = useCallback(() => {
const el = rootRef.current;
if (!el) return;
el.style.setProperty("--sg-tilt-rx", "0deg");
el.style.setProperty("--sg-tilt-ry", "0deg");
el.style.setProperty("--sg-tilt-gx", "50%");
el.style.setProperty("--sg-tilt-gy", "50%");
el.style.setProperty("--sg-tilt-scale", "1");
el.style.setProperty("--sg-tilt-glare", "0");
}, []);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion || e.pointerType === "touch") return;
apply(e.clientX, e.clientY, true);
},
[apply, reducedMotion],
);
const lag = Math.min(Math.max(smoothing, 0.05), 1).toFixed(3);
return (
<div
ref={rootRef}
onPointerMove={onPointerMove}
onPointerLeave={rest}
onPointerCancel={rest}
className={cn("[transform-style:preserve-3d]", className)}
style={
{
perspective: `${perspective}px`,
"--sg-tilt-rx": "0deg",
"--sg-tilt-ry": "0deg",
"--sg-tilt-scale": "1",
"--sg-tilt-glare": "0",
transition: `--sg-tilt-gx ${lag}s ${TILT_EASE}, --sg-tilt-gy ${lag}s ${TILT_EASE}`,
} as React.CSSProperties
}
>
<style>{TILT_CSS}</style>
<div
className="h-full rounded-[2rem] border border-hairline bg-raised/50 p-1.5 will-change-transform"
style={{
transform:
"rotateX(var(--sg-tilt-rx)) rotateY(var(--sg-tilt-ry)) scale(var(--sg-tilt-scale))",
transformStyle: "preserve-3d",
transition: `transform ${lag}s ${TILT_EASE}`,
}}
>
<div
className="relative h-full overflow-hidden rounded-[calc(2rem-0.375rem)] bg-panel"
style={{ boxShadow: "inset 0 1px 1px rgb(255 255 255 / 0.09)" }}
>
{children}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: `radial-gradient(circle ${Math.round(glareSize * 100)}% at var(--sg-tilt-gx) var(--sg-tilt-gy), ${glareColor}, transparent 70%)`,
mixBlendMode: "plus-lighter",
opacity: "var(--sg-tilt-glare)",
transition: `opacity ${lag}s ${TILT_EASE}`,
}}
/>
</div>
</div>
</div>
);
}