+
+ {group.icon}
+
{group.name}
+
+
{skills
.filter((skill) => skill.group === group.id)
.slice(0, 4) // 显示前4个
.map((skill) => (
-
-
{skill.name}
-
-
-
{skill.level}%
+
+
+
{skill.name}
+
+ `${Math.round(value)}%`}
+ />
+
+
+
+
))}
{skills.filter((skill) => skill.group === group.id).length > 4 && (
-
+
+{skills.filter((skill) => skill.group === group.id).length - 4} 更多技能
-
+
)}
-
+
))}
)
-}
\ No newline at end of file
+}
diff --git a/src/components/ui/3d-card.tsx b/src/components/ui/3d-card.tsx
new file mode 100644
index 0000000..9f4c491
--- /dev/null
+++ b/src/components/ui/3d-card.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import type React from "react"
+
+import { useState, useRef, type ReactNode } from "react"
+import { motion, useMotionValue, useSpring, useTransform } from "framer-motion"
+import { cn } from "@/lib/utils"
+
+interface ThreeDCardProps {
+ children: ReactNode
+ className?: string
+ glareColor?: string
+ borderColor?: string
+ backgroundGradient?: string
+ rotationIntensity?: number
+ glareIntensity?: number
+ shadowColor?: string
+}
+
+export default function ThreeDCard({
+ children,
+ className,
+ glareColor = "rgba(255, 255, 255, 0.4)",
+ borderColor = "rgba(255, 255, 255, 0.1)",
+ backgroundGradient = "radial-gradient(circle at center, rgba(255, 255, 255, 0.05) 0%, rgba(0, 0, 0, 0) 70%)",
+ rotationIntensity = 15,
+ glareIntensity = 0.5,
+ shadowColor = "rgba(0, 0, 0, 0.3)",
+ }: ThreeDCardProps) {
+ const cardRef = useRef
(null)
+ const [isHovered, setIsHovered] = useState(false)
+
+ // Motion values for rotation and glare effect
+ const mouseX = useMotionValue(0)
+ const mouseY = useMotionValue(0)
+
+ // Smooth spring physics for rotation
+ const springConfig = { damping: 20, stiffness: 300 }
+ const rotateX = useSpring(useTransform(mouseY, [0, 1], [rotationIntensity, -rotationIntensity]), springConfig)
+ const rotateY = useSpring(useTransform(mouseX, [0, 1], [-rotationIntensity, rotationIntensity]), springConfig)
+
+ // Glare effect position
+ const glareX = useSpring(mouseX, springConfig)
+ const glareY = useSpring(mouseY, springConfig)
+ const glareOpacity = useSpring(useMotionValue(0), { damping: 25, stiffness: 200 })
+
+ function handleMouseMove(e: React.MouseEvent) {
+ if (!cardRef.current) return
+
+ const rect = cardRef.current.getBoundingClientRect()
+ const width = rect.width
+ const height = rect.height
+
+ // Calculate normalized mouse position (0 to 1)
+ const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / width))
+ const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / height))
+
+ mouseX.set(normalizedX)
+ mouseY.set(normalizedY)
+ }
+
+ function handleMouseEnter() {
+ setIsHovered(true)
+ glareOpacity.set(glareIntensity)
+ }
+
+ function handleMouseLeave() {
+ setIsHovered(false)
+ mouseX.set(0.5)
+ mouseY.set(0.5)
+ glareOpacity.set(0)
+ }
+
+ return (
+
+ {/* Background gradient */}
+
+
+ {/* Glare effect */}
+
+
+ {/* Content */}
+ {children}
+
+ )
+}
diff --git a/src/components/ui/animated-counter.tsx b/src/components/ui/animated-counter.tsx
new file mode 100644
index 0000000..2fd3baf
--- /dev/null
+++ b/src/components/ui/animated-counter.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { useInView } from "framer-motion"
+
+interface AnimatedCounterProps {
+ from: number
+ to: number
+ duration?: number
+ delay?: number
+ formatter?: (value: number) => string
+ className?: string
+}
+
+export default function AnimatedCounter({
+ from,
+ to,
+ duration = 2,
+ delay = 0,
+ formatter = (value) => Math.round(value).toString(),
+ className,
+ }: AnimatedCounterProps) {
+ const [count, setCount] = useState(from)
+ const nodeRef = useRef(null)
+ const isInView = useInView(nodeRef, { once: true, amount: 0.5 })
+ const [hasAnimated, setHasAnimated] = useState(false)
+
+ useEffect(() => {
+ if (!isInView || hasAnimated) return
+
+ let startTimestamp: number | null = null
+ const step = (timestamp: number) => {
+ if (!startTimestamp) startTimestamp = timestamp
+
+ const progress = Math.min((timestamp - startTimestamp) / (duration * 1000), 1)
+ const currentCount = from + progress * (to - from)
+
+ setCount(currentCount)
+
+ if (progress < 1) {
+ window.requestAnimationFrame(step)
+ } else {
+ setHasAnimated(true)
+ }
+ }
+
+ // Add delay before starting animation
+ const timer = setTimeout(() => {
+ window.requestAnimationFrame(step)
+ }, delay * 1000)
+
+ return () => clearTimeout(timer)
+ }, [from, to, duration, delay, isInView, hasAnimated])
+
+ return (
+
+ {formatter(count)}
+
+ )
+}
diff --git a/src/components/ui/animated-divider.tsx b/src/components/ui/animated-divider.tsx
new file mode 100644
index 0000000..48a6009
--- /dev/null
+++ b/src/components/ui/animated-divider.tsx
@@ -0,0 +1,128 @@
+"use client"
+
+import {useRef} from "react"
+import {motion, useInView} from "framer-motion"
+import {cn} from "@/lib/utils"
+
+interface AnimatedDividerProps {
+ className?: string
+ width?: string
+ height?: string
+ color?: string
+ animationDuration?: number
+ delay?: number
+ direction?: "horizontal" | "vertical"
+ pattern?: "solid" | "dashed" | "dotted" | "zigzag" | "wave"
+}
+
+export default function AnimatedDivider({
+ className,
+ width = "100%",
+ height = "2px",
+ color = "var(--primary)",
+ animationDuration = 1.5,
+ delay = 0,
+ direction = "horizontal",
+ pattern = "solid",
+ }: AnimatedDividerProps) {
+ const ref = useRef(null)
+ const isInView = useInView(ref, {once: true, amount: 0.5})
+
+ // Generate SVG path for patterns
+ const generatePath = () => {
+ if (direction === "horizontal") {
+ switch (pattern) {
+ case "zigzag":
+ return "M0,10 L10,0 L20,10 L30,0 L40,10 L50,0 L60,10 L70,0 L80,10 L90,0 L100,10"
+ case "wave":
+ return "M0,5 C25,-5 25,15 50,5 C75,-5 75,15 100,5"
+ default:
+ return ""
+ }
+ } else {
+ switch (pattern) {
+ case "zigzag":
+ return "M5,0 L0,10 L5,20 L0,30 L5,40 L0,50 L5,60 L0,70 L5,80 L0,90 L5,100"
+ case "wave":
+ return "M5,0 C-5,25 15,25 5,50 C-5,75 15,75 5,100"
+ default:
+ return ""
+ }
+ }
+ }
+
+ // Render SVG for zigzag and wave patterns
+ if (pattern === "zigzag" || pattern === "wave") {
+ const svgWidth = direction === "horizontal" ? "100%" : height
+ const svgHeight = direction === "horizontal" ? height : "100%"
+ const viewBox = direction === "horizontal" ? "0 0 100 10" : "0 0 10 100"
+ const pathLength = 100
+
+ return (
+
+
+
+
+
+ )
+ }
+
+ // Render standard divider for solid, dashed, dotted
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ui/animated-gradient-text.tsx b/src/components/ui/animated-gradient-text.tsx
new file mode 100644
index 0000000..f9e29ae
--- /dev/null
+++ b/src/components/ui/animated-gradient-text.tsx
@@ -0,0 +1,59 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { motion } from "framer-motion"
+import { cn } from "@/lib/utils"
+
+interface AnimatedGradientTextProps {
+ text: string
+ className?: string
+ gradient?: string
+ animate?: boolean
+ delay?: number
+}
+
+export default function AnimatedGradientText({
+ text,
+ className,
+ gradient = "from-blue-600 via-purple-600 to-blue-600",
+ animate = true,
+ delay = 0,
+ }: AnimatedGradientTextProps) {
+ const textRef = useRef(null)
+
+ useEffect(() => {
+ if (!animate || !textRef.current) return
+
+ const loadGsap = async () => {
+ const { gsap } = await import("gsap")
+
+ gsap.to(textRef.current, {
+ backgroundPosition: "-200% center",
+ ease: "linear",
+ duration: 15,
+ repeat: -1,
+ delay,
+ })
+ }
+
+ loadGsap()
+ }, [animate, delay])
+
+ return (
+
+ {text}
+
+ )
+}
diff --git a/src/components/ui/cursor-spotlight.tsx b/src/components/ui/cursor-spotlight.tsx
new file mode 100644
index 0000000..2f23c38
--- /dev/null
+++ b/src/components/ui/cursor-spotlight.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import type React from "react"
+
+import {useRef, useEffect} from "react"
+import {motion, useMotionValue, useSpring} from "framer-motion"
+
+interface CursorSpotlightProps {
+ children: React.ReactNode
+ className?: string
+ size?: number
+ color?: string
+ blur?: number
+ opacity?: number
+ delay?: number
+}
+
+export default function CursorSpotlight({
+ children,
+ className = "",
+ size = 400,
+ color = "rgba(100, 150, 255, 0.15)",
+ blur = 100,
+ opacity = 0.8,
+ delay = 0.1,
+ }: CursorSpotlightProps) {
+ const containerRef = useRef(null)
+
+ // Motion values for cursor position
+ const mouseX = useMotionValue(0)
+ const mouseY = useMotionValue(0)
+
+ // Spring physics for smooth movement
+ const springConfig = {damping: 20, stiffness: 200, mass: 0.5}
+ const spotlightX = useSpring(mouseX, springConfig)
+ const spotlightY = useSpring(mouseY, springConfig)
+
+ useEffect(() => {
+ if (!containerRef.current) return
+
+ const container = containerRef.current
+
+ const handleMouseMove = (e: MouseEvent) => {
+ const rect = container.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ mouseX.set(x)
+ mouseY.set(y)
+ }
+
+ const handleMouseLeave = () => {
+ // Center the spotlight when mouse leaves
+ const rect = container.getBoundingClientRect()
+ mouseX.set(rect.width / 2)
+ mouseY.set(rect.height / 2)
+ }
+
+ // Initialize position to center
+ handleMouseLeave()
+
+ container.addEventListener("mousemove", handleMouseMove)
+ container.addEventListener("mouseleave", handleMouseLeave)
+
+ return () => {
+ container.removeEventListener("mousemove", handleMouseMove)
+ container.removeEventListener("mouseleave", handleMouseLeave)
+ }
+ }, [mouseX, mouseY])
+
+ return (
+
+ {/* Spotlight effect */}
+
+
+ {/* Content */}
+
{children}
+
+ )
+}
diff --git a/src/components/ui/floating-icons.tsx b/src/components/ui/floating-icons.tsx
new file mode 100644
index 0000000..b73696e
--- /dev/null
+++ b/src/components/ui/floating-icons.tsx
@@ -0,0 +1,70 @@
+"use client"
+
+import type React from "react"
+
+import { useEffect, useRef } from "react"
+import { motion } from "framer-motion"
+import { useTheme } from "next-themes"
+
+interface FloatingIconsProps {
+ icons: React.ReactNode[]
+ className?: string
+}
+
+export default function FloatingIcons({ icons, className }: FloatingIconsProps) {
+ const containerRef = useRef(null)
+ const { theme } = useTheme()
+
+ useEffect(() => {
+ if (!containerRef.current) return
+
+ const loadGsap = async () => {
+ const { gsap } = await import("gsap")
+
+ const iconElements = containerRef.current?.querySelectorAll(".floating-icon")
+
+ iconElements?.forEach((icon, index) => {
+ // Random initial position
+ gsap.set(icon, {
+ x: Math.random() * 100 - 50,
+ y: Math.random() * 100 - 50,
+ rotation: Math.random() * 20 - 10,
+ opacity: 0.7 + Math.random() * 0.3,
+ scale: 0.8 + Math.random() * 0.4,
+ })
+
+ // Floating animation
+ gsap.to(icon, {
+ x: `+=${Math.random() * 40 - 20}`,
+ y: `+=${Math.random() * 40 - 20}`,
+ rotation: `+=${Math.random() * 20 - 10}`,
+ opacity: 0.7 + Math.random() * 0.3,
+ scale: 0.8 + Math.random() * 0.4,
+ duration: 3 + Math.random() * 4,
+ ease: "sine.inOut",
+ repeat: -1,
+ yoyo: true,
+ delay: Math.random() * 2,
+ })
+ })
+ }
+
+ loadGsap()
+ }, [icons.length, theme])
+
+ return (
+
+ {icons.map((icon, index) => (
+
+ {icon}
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/magnetic-button.tsx b/src/components/ui/magnetic-button.tsx
new file mode 100644
index 0000000..9acffcd
--- /dev/null
+++ b/src/components/ui/magnetic-button.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import type React from "react"
+
+import { useState, useRef, type ReactNode } from "react"
+import { motion, useMotionValue, useSpring, useTransform } from "framer-motion"
+import { cn } from "@/lib/utils"
+
+interface MagneticButtonProps {
+ children: ReactNode
+ className?: string
+ strength?: number
+ onClick?: () => void
+ disabled?: boolean
+}
+
+export default function MagneticButton({
+ children,
+ className,
+ strength = 40,
+ onClick,
+ disabled = false,
+ }: MagneticButtonProps) {
+ const buttonRef = useRef(null)
+ const [isMagnetic, setIsMagnetic] = useState(true)
+
+ // Motion values
+ const mouseX = useMotionValue(0)
+ const mouseY = useMotionValue(0)
+
+ // Spring physics for smooth movement
+ const springConfig = { damping: 15, stiffness: 150 }
+ const x = useSpring(useTransform(mouseX, [0, 1], [-strength, strength]), springConfig)
+ const y = useSpring(useTransform(mouseY, [0, 1], [-strength, strength]), springConfig)
+
+ // Handle mouse move
+ function handleMouseMove(e: React.MouseEvent) {
+ if (!buttonRef.current || disabled || !isMagnetic) return
+
+ const rect = buttonRef.current.getBoundingClientRect()
+ const centerX = rect.left + rect.width / 2
+ const centerY = rect.top + rect.height / 2
+
+ // Calculate distance from center
+ const distanceX = e.clientX - centerX
+ const distanceY = e.clientY - centerY
+
+ // Normalize to -1 to 1 range
+ mouseX.set(distanceX / (rect.width / 2))
+ mouseY.set(distanceY / (rect.height / 2))
+ }
+
+ // Reset position when mouse leaves
+ function handleMouseLeave() {
+ mouseX.set(0)
+ mouseY.set(0)
+ }
+
+ // Temporarily disable magnetic effect on click
+ function handleClick() {
+ if (disabled) return
+
+ setIsMagnetic(false)
+ mouseX.set(0)
+ mouseY.set(0)
+
+ // Re-enable after animation completes
+ setTimeout(() => setIsMagnetic(true), 500)
+
+ if (onClick) onClick()
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ui/particle-container.tsx b/src/components/ui/particle-container.tsx
new file mode 100644
index 0000000..ceffeb9
--- /dev/null
+++ b/src/components/ui/particle-container.tsx
@@ -0,0 +1,266 @@
+"use client"
+
+import type React from "react"
+
+import {useRef, useEffect, useState, useMemo} from "react"
+import {useTheme} from "next-themes"
+
+interface Particle {
+ x: number
+ y: number
+ size: number
+ speedX: number
+ speedY: number
+ color: string
+ opacity: number
+ life: number
+ maxLife: number
+}
+
+interface ParticleContainerProps {
+ className?: string
+ particleCount?: number
+ particleSize?: [number, number]
+ particleSpeed?: number
+ particleLife?: [number, number]
+ colorPalette?: string[]
+ interactive?: boolean
+ interactionRadius?: number
+ interactionForce?: number
+ children?: React.ReactNode
+}
+
+export default function ParticleContainer({
+ className = "",
+ particleCount = 50,
+ particleSize = [1, 3],
+ particleSpeed = 0.5,
+ particleLife = [5, 10],
+ colorPalette,
+ interactive = true,
+ interactionRadius = 100,
+ interactionForce = 1,
+ children,
+ }: ParticleContainerProps) {
+ const containerRef = useRef(null)
+ const canvasRef = useRef(null)
+ const particlesRef = useRef([])
+ const animationRef = useRef(0)
+ const mouseRef = useRef<{ x: number; y: number; active: boolean }>({x: 0, y: 0, active: false})
+ const {theme} = useTheme()
+ const [dimensions, setDimensions] = useState({width: 0, height: 0})
+ const [isInitialized, setIsInitialized] = useState(false)
+
+ // Set default color palette based on theme - memoize to prevent recreation on every render
+ const defaultLightPalette = ["#3b82f6", "#8b5cf6", "#ec4899", "#10b981", "#f59e0b"]
+ const defaultDarkPalette = ["#60a5fa", "#a78bfa", "#f472b6", "#34d399", "#fbbf24"]
+
+ const effectiveColorPalette = useMemo(() => {
+ return colorPalette || (theme === "dark" ? defaultDarkPalette : defaultLightPalette)
+ }, [colorPalette, theme, defaultLightPalette, defaultDarkPalette])
+
+ // Create a single particle
+ const createParticle = (width: number, height: number): Particle => {
+ const size = Math.random() * (particleSize[1] - particleSize[0]) + particleSize[0]
+ const maxLife = Math.random() * (particleLife[1] - particleLife[0]) + particleLife[0]
+
+ return {
+ x: Math.random() * width,
+ y: Math.random() * height,
+ size,
+ speedX: (Math.random() - 0.5) * particleSpeed,
+ speedY: (Math.random() - 0.5) * particleSpeed,
+ color: effectiveColorPalette[Math.floor(Math.random() * effectiveColorPalette.length)],
+ opacity: Math.random() * 0.5 + 0.2,
+ life: 0,
+ maxLife,
+ }
+ }
+
+ // Initialize particles
+ useEffect(() => {
+ if (!containerRef.current || isInitialized) return
+
+ const {width, height} = containerRef.current.getBoundingClientRect()
+
+ if (width === 0 || height === 0) return // Skip if container has no dimensions yet
+
+ setDimensions({width, height})
+
+ // Initialize particles
+ particlesRef.current = Array.from({length: particleCount}, () => createParticle(width, height))
+
+ // Set canvas dimensions
+ if (canvasRef.current) {
+ canvasRef.current.width = width
+ canvasRef.current.height = height
+ }
+
+ setIsInitialized(true)
+ }, [particleCount, isInitialized, particleSize, particleSpeed, particleLife, effectiveColorPalette])
+
+ // Animation loop
+ const animate = () => {
+ if (!canvasRef.current || !containerRef.current) return
+
+ const canvas = canvasRef.current
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return
+
+ const {width, height} = dimensions
+
+ // Clear canvas
+ ctx.clearRect(0, 0, width, height)
+
+ // Performance optimization: only do full animation when in viewport
+ const rect = canvas.getBoundingClientRect()
+ const isVisible =
+ rect.bottom >= 0 &&
+ rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
+ rect.right >= 0 &&
+ rect.left <= (window.innerWidth || document.documentElement.clientWidth)
+
+ // If not in viewport, reduce frame rate
+ if (!isVisible) {
+ animationRef.current = requestAnimationFrame(() => {
+ setTimeout(animate, 100) // Reduce frame rate
+ })
+ return
+ }
+
+ // Update and draw particles
+ particlesRef.current.forEach((particle, index) => {
+ // Update position
+ particle.x += particle.speedX
+ particle.y += particle.speedY
+
+ // Apply mouse interaction
+ if (interactive && mouseRef.current.active) {
+ const dx = particle.x - mouseRef.current.x
+ const dy = particle.y - mouseRef.current.y
+ const distance = Math.sqrt(dx * dx + dy * dy)
+
+ if (distance < interactionRadius) {
+ const force = ((interactionRadius - distance) / interactionRadius) * interactionForce
+ particle.speedX += (dx / distance) * force * 0.2
+ particle.speedY += (dy / distance) * force * 0.2
+ }
+ }
+
+ // Apply friction
+ particle.speedX *= 0.99
+ particle.speedY *= 0.99
+
+ // Boundary check with bounce
+ if (particle.x < 0 || particle.x > width) {
+ particle.speedX *= -1
+ particle.x = Math.max(0, Math.min(width, particle.x))
+ }
+ if (particle.y < 0 || particle.y > height) {
+ particle.speedY *= -1
+ particle.y = Math.max(0, Math.min(height, particle.y))
+ }
+
+ // Update life
+ particle.life += 0.01
+ if (particle.life >= particle.maxLife) {
+ particlesRef.current[index] = createParticle(width, height)
+ }
+
+ // Calculate opacity based on life
+ const lifeRatio = particle.life / particle.maxLife
+ const fadeInOut =
+ lifeRatio < 0.2
+ ? lifeRatio * 5 // Fade in
+ : lifeRatio > 0.8
+ ? (1 - lifeRatio) * 5 // Fade out
+ : 1 // Full opacity
+
+ // Draw particle
+ ctx.beginPath()
+ ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)
+ ctx.fillStyle =
+ particle.color +
+ Math.floor(particle.opacity * fadeInOut * 255)
+ .toString(16)
+ .padStart(2, "0")
+ ctx.fill()
+ })
+
+ animationRef.current = requestAnimationFrame(animate)
+ }
+
+ // Start animation when initialized
+ useEffect(() => {
+ if (!isInitialized) return
+
+ animationRef.current = requestAnimationFrame(animate)
+
+ return () => {
+ cancelAnimationFrame(animationRef.current)
+ }
+ }, [isInitialized])
+
+ // Handle resize
+ useEffect(() => {
+ const handleResize = () => {
+ if (!containerRef.current) return
+
+ const {width, height} = containerRef.current.getBoundingClientRect()
+
+ if (width === 0 || height === 0) return // Skip if container has no dimensions
+
+ setDimensions({width, height})
+
+ if (canvasRef.current) {
+ canvasRef.current.width = width
+ canvasRef.current.height = height
+ }
+
+ // Reposition particles within new boundaries
+ particlesRef.current.forEach((particle) => {
+ particle.x = Math.min(particle.x, width)
+ particle.y = Math.min(particle.y, height)
+ })
+ }
+
+ window.addEventListener("resize", handleResize)
+ return () => window.removeEventListener("resize", handleResize)
+ }, [])
+
+ // Handle mouse interaction
+ useEffect(() => {
+ if (!interactive || !containerRef.current) return
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!containerRef.current) return
+
+ const rect = containerRef.current.getBoundingClientRect()
+ mouseRef.current = {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ active: true,
+ }
+ }
+
+ const handleMouseLeave = () => {
+ mouseRef.current.active = false
+ }
+
+ const container = containerRef.current
+ container.addEventListener("mousemove", handleMouseMove)
+ container.addEventListener("mouseleave", handleMouseLeave)
+
+ return () => {
+ container.removeEventListener("mousemove", handleMouseMove)
+ container.removeEventListener("mouseleave", handleMouseLeave)
+ }
+ }, [interactive])
+
+ return (
+
+ )
+}
diff --git a/src/components/ui/scroll-reveal.tsx b/src/components/ui/scroll-reveal.tsx
new file mode 100644
index 0000000..7a1a3dc
--- /dev/null
+++ b/src/components/ui/scroll-reveal.tsx
@@ -0,0 +1,132 @@
+"use client"
+
+import React from "react"
+
+import {useRef, useEffect, useState} from "react"
+import {motion, useInView, type Variants} from "framer-motion"
+
+type Direction = "up" | "down" | "left" | "right" | "none"
+type Effect = "fade" | "slide" | "zoom" | "flip" | "none"
+
+interface ScrollRevealProps {
+ children: React.ReactNode
+ direction?: Direction
+ effect?: Effect
+ duration?: number
+ delay?: number
+ threshold?: number
+ once?: boolean
+ className?: string
+ staggerChildren?: boolean
+ staggerDelay?: number
+}
+
+export default function ScrollReveal({
+ children,
+ direction = "up",
+ effect = "fade",
+ duration = 0.5,
+ delay = 0,
+ threshold = 0.1,
+ once = true,
+ className = "",
+ staggerChildren = false,
+ staggerDelay = 0.1,
+ }: ScrollRevealProps) {
+ const ref = useRef(null)
+ const isInView = useInView(ref, {amount: threshold, once})
+ const [childrenCount, setChildrenCount] = useState(0)
+
+ // Count direct children for staggering
+ useEffect(() => {
+ if (ref.current && staggerChildren) {
+ setChildrenCount(ref.current.children.length)
+ }
+ }, [staggerChildren])
+
+ // Define animation variants based on direction and effect
+ const getVariants = (): Variants => {
+ // Initial state based on direction
+ const getInitialPosition = () => {
+ switch (direction) {
+ case "up":
+ return {y: 50}
+ case "down":
+ return {y: -50}
+ case "left":
+ return {x: 50}
+ case "right":
+ return {x: -50}
+ case "none":
+ return {}
+ }
+ }
+
+ // Initial state based on effect
+ const getInitialEffect = () => {
+ switch (effect) {
+ case "fade":
+ return {opacity: 0}
+ case "zoom":
+ return {scale: 0.9, opacity: 0}
+ case "flip":
+ return {rotateX: 30, opacity: 0}
+ case "none":
+ return {}
+ default:
+ return {}
+ }
+ }
+
+ return {
+ hidden: {
+ ...getInitialPosition(),
+ ...getInitialEffect(),
+ },
+ visible: (i = 0) => ({
+ x: 0,
+ y: 0,
+ scale: 1,
+ rotateX: 0,
+ opacity: 1,
+ transition: {
+ duration,
+ delay: delay + (staggerChildren ? i * staggerDelay : 0),
+ ease: [0.25, 0.1, 0.25, 1], // Cubic bezier for smooth easing
+ },
+ }),
+ }
+ }
+
+ // If staggering children, wrap each child in a motion.div
+ if (staggerChildren && React.Children.count(children) > 0) {
+ return (
+
+ {React.Children.map(children, (child, index) => (
+
+ {child}
+
+ ))}
+
+ )
+ }
+
+ // Otherwise, animate the container as a whole
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ui/text-reveal-effect.tsx b/src/components/ui/text-reveal-effect.tsx
new file mode 100644
index 0000000..ec277f9
--- /dev/null
+++ b/src/components/ui/text-reveal-effect.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import type React from "react"
+
+import { useEffect, useRef } from "react"
+import { motion, useInView, useAnimation } from "framer-motion"
+
+interface TextRevealEffectProps {
+ text: string
+ className?: string
+ once?: boolean
+ delay?: number
+ duration?: number
+ as?: React.ElementType
+}
+
+export default function TextRevealEffect({
+ text,
+ className,
+ once = true,
+ delay = 0,
+ duration = 0.05,
+ as: Component = "div",
+ }: TextRevealEffectProps) {
+ const controls = useAnimation()
+ const ref = useRef(null)
+ const isInView = useInView(ref, { once, amount: 0.5 })
+
+ // Split text into words and characters
+ const words = text.split(" ")
+
+ useEffect(() => {
+ if (isInView) {
+ controls.start("visible")
+ } else if (!once) {
+ controls.start("hidden")
+ }
+ }, [isInView, controls, once])
+
+ const wordVariants = {
+ hidden: {},
+ visible: {},
+ }
+
+ const characterVariants = {
+ hidden: {
+ opacity: 0,
+ y: 20,
+ },
+ visible: (i: number) => ({
+ opacity: 1,
+ y: 0,
+ transition: {
+ duration: 0.4,
+ ease: [0.2, 0.65, 0.3, 0.9],
+ delay: delay + i * duration,
+ },
+ }),
+ }
+
+ return (
+
+ {words.map((word, wordIndex) => (
+
+ {word.split("").map((character, charIndex) => (
+
+ {character}
+
+ ))}
+ {wordIndex < words.length - 1 && }
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/typing-effect.tsx b/src/components/ui/typing-effect.tsx
new file mode 100644
index 0000000..853e284
--- /dev/null
+++ b/src/components/ui/typing-effect.tsx
@@ -0,0 +1,132 @@
+"use client"
+
+import {useState, useEffect, useRef} from "react"
+import {motion, useInView} from "framer-motion"
+
+interface TypingEffectProps {
+ text: string | string[]
+ className?: string
+ typingSpeed?: number
+ deletingSpeed?: number
+ delayBeforeDeleting?: number
+ delayBeforeTyping?: number
+ cursor?: boolean
+ cursorStyle?: string
+ cursorColor?: string
+ repeat?: boolean
+ onComplete?: () => void
+}
+
+export default function TypingEffect({
+ text,
+ className = "",
+ typingSpeed = 50,
+ deletingSpeed = 30,
+ delayBeforeDeleting = 1500,
+ delayBeforeTyping = 500,
+ cursor = true,
+ cursorStyle = "|",
+ cursorColor = "var(--primary)",
+ repeat = false,
+ onComplete,
+ }: TypingEffectProps) {
+ const [displayText, setDisplayText] = useState("")
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const [isTyping, setIsTyping] = useState(true)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [isPaused, setIsPaused] = useState(false)
+ const [isComplete, setIsComplete] = useState(false)
+ const containerRef = useRef(null)
+ const isInView = useInView(containerRef, {once: true, amount: 0.5})
+
+ // Convert single string to array for consistent handling
+ const textArray = Array.isArray(text) ? text : [text]
+
+ useEffect(() => {
+ if (!isInView || isComplete) return
+
+ let timeout: NodeJS.Timeout
+
+ const handleTyping = () => {
+ if (isPaused) return
+
+ const currentText = textArray[currentIndex]
+
+ if (isTyping) {
+ if (displayText.length < currentText.length) {
+ // Still typing
+ setDisplayText(currentText.substring(0, displayText.length + 1))
+ timeout = setTimeout(handleTyping, typingSpeed)
+ } else {
+ // Finished typing
+ if (textArray.length === 1 || (currentIndex === textArray.length - 1 && !repeat)) {
+ // All done, no more texts to type
+ setIsComplete(true)
+ if (onComplete) onComplete()
+ } else {
+ // Pause before deleting
+ setIsPaused(true)
+ timeout = setTimeout(() => {
+ setIsPaused(false)
+ setIsDeleting(true)
+ }, delayBeforeDeleting)
+ }
+ }
+ } else if (isDeleting) {
+ if (displayText.length > 0) {
+ // Still deleting
+ setDisplayText(displayText.substring(0, displayText.length - 1))
+ timeout = setTimeout(handleTyping, deletingSpeed)
+ } else {
+ // Finished deleting
+ setIsDeleting(false)
+
+ // Move to next text or loop back
+ const nextIndex = (currentIndex + 1) % textArray.length
+ setCurrentIndex(nextIndex)
+
+ // Pause before typing next text
+ setIsPaused(true)
+ timeout = setTimeout(() => {
+ setIsPaused(false)
+ setIsTyping(true)
+ }, delayBeforeTyping)
+ }
+ }
+ }
+
+ timeout = setTimeout(handleTyping, 0)
+
+ return () => clearTimeout(timeout)
+ }, [
+ displayText,
+ currentIndex,
+ isTyping,
+ isDeleting,
+ isPaused,
+ isComplete,
+ isInView,
+ textArray,
+ typingSpeed,
+ deletingSpeed,
+ delayBeforeDeleting,
+ delayBeforeTyping,
+ repeat,
+ onComplete,
+ ])
+
+ return (
+
+ {displayText}
+ {cursor && !isComplete && (
+
+ {cursorStyle}
+
+ )}
+
+ )
+}