Files
resume/components/ui/magic-card.tsx
evan 6fec90ea71 feat: build resume website with MagicUI components
- 6 sections: Hero, About, Experience, Skills, Projects, Contact
- MagicUI: Globe, Particles, Meteors, AnimatedList, IconCloud, BentoGrid
- Dark mode support, scroll-triggered animations
- Static export ready for deployment
2026-04-14 15:09:48 +08:00

223 lines
6.2 KiB
TypeScript

"use client"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
motion,
useMotionTemplate,
useMotionValue,
useSpring,
} from "motion/react"
import { useTheme } from "next-themes"
import { cn } from "@/lib/utils"
interface MagicCardBaseProps {
children?: React.ReactNode
className?: string
gradientSize?: number
gradientFrom?: string
gradientTo?: string
}
interface MagicCardGradientProps extends MagicCardBaseProps {
mode?: "gradient"
gradientColor?: string
gradientOpacity?: number
glowFrom?: never
glowTo?: never
glowAngle?: never
glowSize?: never
glowBlur?: never
glowOpacity?: never
}
interface MagicCardOrbProps extends MagicCardBaseProps {
mode: "orb"
glowFrom?: string
glowTo?: string
glowAngle?: number
glowSize?: number
glowBlur?: number
glowOpacity?: number
gradientColor?: never
gradientOpacity?: never
}
type MagicCardProps = MagicCardGradientProps | MagicCardOrbProps
type ResetReason = "enter" | "leave" | "global" | "init"
function isOrbMode(props: MagicCardProps): props is MagicCardOrbProps {
return props.mode === "orb"
}
export function MagicCard(props: MagicCardProps) {
const {
children,
className,
gradientSize = 200,
gradientColor = "#262626",
gradientOpacity = 0.8,
gradientFrom = "#9E7AFF",
gradientTo = "#FE8BBB",
mode = "gradient",
} = props
const glowFrom = isOrbMode(props) ? (props.glowFrom ?? "#ee4f27") : "#ee4f27"
const glowTo = isOrbMode(props) ? (props.glowTo ?? "#6b21ef") : "#6b21ef"
const glowAngle = isOrbMode(props) ? (props.glowAngle ?? 90) : 90
const glowSize = isOrbMode(props) ? (props.glowSize ?? 420) : 420
const glowBlur = isOrbMode(props) ? (props.glowBlur ?? 60) : 60
const glowOpacity = isOrbMode(props) ? (props.glowOpacity ?? 0.9) : 0.9
const { theme, systemTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const isDarkTheme = useMemo(() => {
if (!mounted) return true
const currentTheme = theme === "system" ? systemTheme : theme
return currentTheme === "dark"
}, [theme, systemTheme, mounted])
const mouseX = useMotionValue(-gradientSize)
const mouseY = useMotionValue(-gradientSize)
const orbX = useSpring(mouseX, { stiffness: 250, damping: 30, mass: 0.6 })
const orbY = useSpring(mouseY, { stiffness: 250, damping: 30, mass: 0.6 })
const orbVisible = useSpring(0, { stiffness: 300, damping: 35 })
const modeRef = useRef(mode)
const glowOpacityRef = useRef(glowOpacity)
const gradientSizeRef = useRef(gradientSize)
useEffect(() => {
modeRef.current = mode
}, [mode])
useEffect(() => {
glowOpacityRef.current = glowOpacity
}, [glowOpacity])
useEffect(() => {
gradientSizeRef.current = gradientSize
}, [gradientSize])
const reset = useCallback(
(reason: ResetReason = "leave") => {
const currentMode = modeRef.current
if (currentMode === "orb") {
if (reason === "enter") orbVisible.set(glowOpacityRef.current)
else orbVisible.set(0)
return
}
const off = -gradientSizeRef.current
mouseX.set(off)
mouseY.set(off)
},
[mouseX, mouseY, orbVisible]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
mouseX.set(e.clientX - rect.left)
mouseY.set(e.clientY - rect.top)
},
[mouseX, mouseY]
)
useEffect(() => {
reset("init")
}, [reset])
useEffect(() => {
const handleGlobalPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) reset("global")
}
const handleBlur = () => reset("global")
const handleVisibility = () => {
if (document.visibilityState !== "visible") reset("global")
}
window.addEventListener("pointerout", handleGlobalPointerOut)
window.addEventListener("blur", handleBlur)
document.addEventListener("visibilitychange", handleVisibility)
return () => {
window.removeEventListener("pointerout", handleGlobalPointerOut)
window.removeEventListener("blur", handleBlur)
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [reset])
return (
<motion.div
className={cn(
"group relative isolate overflow-hidden rounded-[inherit] border border-transparent",
className
)}
onPointerMove={handlePointerMove}
onPointerLeave={() => reset("leave")}
onPointerEnter={() => reset("enter")}
style={{
background: useMotionTemplate`
linear-gradient(var(--color-background) 0 0) padding-box,
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
var(--color-border) 100%
) border-box
`,
}}
>
<div className="bg-background absolute inset-px z-20 rounded-[inherit]" />
{mode === "gradient" && (
<motion.div
suppressHydrationWarning
className="pointer-events-none absolute inset-px z-30 rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientColor},
transparent 100%
)
`,
opacity: gradientOpacity,
}}
/>
)}
{mode === "orb" && (
<motion.div
suppressHydrationWarning
aria-hidden="true"
className="pointer-events-none absolute z-30"
style={{
width: glowSize,
height: glowSize,
x: orbX,
y: orbY,
translateX: "-50%",
translateY: "-50%",
borderRadius: 9999,
filter: `blur(${glowBlur}px)`,
opacity: orbVisible,
background: `linear-gradient(${glowAngle}deg, ${glowFrom}, ${glowTo})`,
mixBlendMode: isDarkTheme ? "screen" : "multiply",
willChange: "transform, opacity",
}}
/>
)}
<div className="relative z-40">{children}</div>
</motion.div>
)
}