mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-11-03 03:18:52 +00:00
271 lines
6.9 KiB
TypeScript
271 lines
6.9 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface Point {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
interface Card3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
children: React.ReactNode
|
|
maxTilt?: number
|
|
perspective?: number
|
|
scale?: number
|
|
speed?: number
|
|
glare?: boolean
|
|
glareMaxOpacity?: number
|
|
className?: string
|
|
}
|
|
|
|
const Card3D = React.forwardRef<HTMLDivElement, Card3DProps>(
|
|
(
|
|
{
|
|
children,
|
|
maxTilt = 15,
|
|
perspective = 1000,
|
|
scale = 1.05,
|
|
speed = 400,
|
|
glare = true,
|
|
glareMaxOpacity = 0.7,
|
|
className,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const cardRef = React.useRef<HTMLDivElement>(null)
|
|
const glareRef = React.useRef<HTMLDivElement>(null)
|
|
const [isHovered, setIsHovered] = React.useState(false)
|
|
const [transforms, setTransforms] = React.useState({
|
|
rotateX: 0,
|
|
rotateY: 0,
|
|
scale: 1,
|
|
glareX: 50,
|
|
glareY: 50,
|
|
glareOpacity: 0,
|
|
})
|
|
|
|
const updateTransforms = React.useCallback(
|
|
(point: Point | null, rect: DOMRect | null) => {
|
|
if (!point || !rect) {
|
|
setTransforms({
|
|
rotateX: 0,
|
|
rotateY: 0,
|
|
scale: 1,
|
|
glareX: 50,
|
|
glareY: 50,
|
|
glareOpacity: 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
const centerX = rect.left + rect.width / 2
|
|
const centerY = rect.top + rect.height / 2
|
|
|
|
const deltaX = (point.x - centerX) / (rect.width / 2)
|
|
const deltaY = (point.y - centerY) / (rect.height / 2)
|
|
|
|
const rotateX = -deltaY * maxTilt
|
|
const rotateY = deltaX * maxTilt
|
|
|
|
const glareX = ((point.x - rect.left) / rect.width) * 100
|
|
const glareY = ((point.y - rect.top) / rect.height) * 100
|
|
|
|
setTransforms({
|
|
rotateX,
|
|
rotateY,
|
|
scale,
|
|
glareX,
|
|
glareY,
|
|
glareOpacity: glare ? glareMaxOpacity : 0,
|
|
})
|
|
},
|
|
[maxTilt, scale, glare, glareMaxOpacity]
|
|
)
|
|
|
|
const handleMouseMove = React.useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!cardRef.current) return
|
|
|
|
const rect = cardRef.current.getBoundingClientRect()
|
|
const point = { x: e.clientX, y: e.clientY }
|
|
updateTransforms(point, rect)
|
|
},
|
|
[updateTransforms]
|
|
)
|
|
|
|
const handleTouchMove = React.useCallback(
|
|
(e: React.TouchEvent) => {
|
|
if (!cardRef.current || e.touches.length === 0) return
|
|
|
|
e.preventDefault()
|
|
const rect = cardRef.current.getBoundingClientRect()
|
|
const touch = e.touches[0]
|
|
const point = { x: touch.clientX, y: touch.clientY }
|
|
updateTransforms(point, rect)
|
|
},
|
|
[updateTransforms]
|
|
)
|
|
|
|
const handleMouseEnter = React.useCallback(() => {
|
|
setIsHovered(true)
|
|
}, [])
|
|
|
|
const handleMouseLeave = React.useCallback(() => {
|
|
setIsHovered(false)
|
|
updateTransforms(null, null)
|
|
}, [updateTransforms])
|
|
|
|
const handleTouchStart = React.useCallback(() => {
|
|
setIsHovered(true)
|
|
}, [])
|
|
|
|
const handleTouchEnd = React.useCallback(() => {
|
|
setIsHovered(false)
|
|
updateTransforms(null, null)
|
|
}, [updateTransforms])
|
|
|
|
const cardStyle: React.CSSProperties = {
|
|
transform: `perspective(${perspective}px) rotateX(${transforms.rotateX}deg) rotateY(${transforms.rotateY}deg) scale(${transforms.scale})`,
|
|
transition: isHovered
|
|
? "none"
|
|
: `transform ${speed}ms cubic-bezier(0.03, 0.98, 0.52, 0.99)`,
|
|
transformStyle: "preserve-3d",
|
|
}
|
|
|
|
const glareStyle: React.CSSProperties = {
|
|
background: `radial-gradient(circle at ${transforms.glareX}% ${transforms.glareY}%, rgba(255,255,255,${transforms.glareOpacity}) 0%, transparent 50%)`,
|
|
transition: isHovered ? "none" : `opacity ${speed}ms ease-out`,
|
|
}
|
|
|
|
React.useImperativeHandle(ref, () => cardRef.current!)
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className={cn(
|
|
"relative rounded-lg border bg-card text-card-foreground shadow-lg cursor-pointer",
|
|
"transform-gpu will-change-transform",
|
|
className
|
|
)}
|
|
style={cardStyle}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
{...props}
|
|
>
|
|
{/* Content Layer */}
|
|
<div className="relative z-10">{children}</div>
|
|
|
|
{/* Glare Effect */}
|
|
{glare && (
|
|
<div
|
|
ref={glareRef}
|
|
className="absolute inset-0 rounded-lg pointer-events-none z-20"
|
|
style={glareStyle}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
Card3D.displayName = "Card3D"
|
|
|
|
// Depth layer components that work with 3D transforms
|
|
const Card3DHeader = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement> & { depth?: number }
|
|
>(({ className, depth = 20, style, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
style={{
|
|
transform: `translateZ(${depth}px)`,
|
|
...style,
|
|
}}
|
|
{...props}
|
|
/>
|
|
))
|
|
Card3DHeader.displayName = "Card3DHeader"
|
|
|
|
const Card3DTitle = React.forwardRef<
|
|
HTMLParagraphElement,
|
|
React.HTMLAttributes<HTMLHeadingElement> & { depth?: number }
|
|
>(({ className, depth = 30, style, ...props }, ref) => (
|
|
<h3
|
|
ref={ref}
|
|
className={cn(
|
|
"text-2xl font-semibold leading-none tracking-tight",
|
|
className
|
|
)}
|
|
style={{
|
|
transform: `translateZ(${depth}px)`,
|
|
...style,
|
|
}}
|
|
{...props}
|
|
/>
|
|
))
|
|
Card3DTitle.displayName = "Card3DTitle"
|
|
|
|
const Card3DDescription = React.forwardRef<
|
|
HTMLParagraphElement,
|
|
React.HTMLAttributes<HTMLParagraphElement> & { depth?: number }
|
|
>(({ className, depth = 10, style, ...props }, ref) => (
|
|
<p
|
|
ref={ref}
|
|
className={cn("text-sm text-muted-foreground", className)}
|
|
style={{
|
|
transform: `translateZ(${depth}px)`,
|
|
...style,
|
|
}}
|
|
{...props}
|
|
/>
|
|
))
|
|
Card3DDescription.displayName = "Card3DDescription"
|
|
|
|
const Card3DContent = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement> & { depth?: number }
|
|
>(({ className, depth = 25, style, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn("p-6 pt-0", className)}
|
|
style={{
|
|
transform: `translateZ(${depth}px)`,
|
|
...style,
|
|
}}
|
|
{...props}
|
|
/>
|
|
))
|
|
Card3DContent.displayName = "Card3DContent"
|
|
|
|
const Card3DFooter = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement> & { depth?: number }
|
|
>(({ className, depth = 15, style, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn("flex items-center p-6 pt-0", className)}
|
|
style={{
|
|
transform: `translateZ(${depth}px)`,
|
|
...style,
|
|
}}
|
|
{...props}
|
|
/>
|
|
))
|
|
Card3DFooter.displayName = "Card3DFooter"
|
|
|
|
export {
|
|
Card3D,
|
|
Card3DHeader,
|
|
Card3DTitle,
|
|
Card3DDescription,
|
|
Card3DContent,
|
|
Card3DFooter,
|
|
}
|