2.5 KB
TiltCard.jsx
import * as React from 'react';
import { useRef, useCallback } from 'react';
// Balatro-style 3D tilt card with glossy shine effect.
// Wraps children and adds mouse-tracking perspective tilt + moving gloss overlay.
export function TiltCard({ children, className = '', style = {}, as: Tag = 'div', ...props }) {
const cardRef = useRef(null);
const glowRef = useRef(null);
const rafRef = useRef(null);
const handleMouseMove = useCallback((e) => {
const card = cardRef.current;
const glow = glowRef.current;
if (!card || !glow) return;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// Tilt: max 8 degrees
const rotateY = ((x - centerX) / centerX) * 8;
const rotateX = ((centerY - y) / centerY) * 8;
card.style.transform = `perspective(600px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.02)`;
// Shine position follows cursor
const percentX = (x / rect.width) * 100;
const percentY = (y / rect.height) * 100;
glow.style.background = `radial-gradient(circle at ${percentX}% ${percentY}%, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.05) 40%, transparent 70%)`;
glow.style.opacity = '1';
});
}, []);
const handleMouseLeave = useCallback(() => {
const card = cardRef.current;
const glow = glowRef.current;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (card) card.style.transform = 'perspective(600px) rotateX(0deg) rotateY(0deg) scale(1)';
if (glow) glow.style.opacity = '0';
}, []);
return (
<Tag
ref={cardRef}
className={className}
style={{
...style,
transition: 'transform 0.25s cubic-bezier(0.03, 0.98, 0.52, 0.99)',
transformStyle: 'preserve-3d',
willChange: 'transform',
position: 'relative',
overflow: 'hidden',
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
{...props}
>
{children}
<div
ref={glowRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 0.3s ease',
borderRadius: 'inherit',
zIndex: 1,
}}
/>
</Tag>
);
}