feat: add new animated components including particle container, typing effect, and scroll reveal

This commit is contained in:
grtsinry43 2025-05-07 13:19:10 +08:00
parent 0b4479beff
commit 90e99c0c51
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
15 changed files with 2320 additions and 258 deletions

View File

@ -23,6 +23,7 @@ import GsapProjectsShowcase from "@/components/sections/project-showcase";
import GsapPersonalIntro from "@/components/sections/personal-intro";
import GsapPhotographySection from "@/components/sections/photography-section";
import GsapRhythmGamesSection from "@/components/sections/rhythm-games-section";
import FinalSection from "@/components/sections/final-section";
export default function HomePage() {
const {theme} = useTheme()
@ -183,6 +184,8 @@ export default function HomePage() {
{/* Contact Section with glass morphism */}
<ContactSection/>
<FinalSection/>
</main>
{/* Footer with subtle animation */}

View File

@ -0,0 +1,660 @@
"use client"
import {useRef, useEffect, useState} from "react"
import {motion, useInView, useScroll, useTransform} from "framer-motion"
import {Github, Mail, ExternalLink, Heart, Star, Download, ArrowRight, Sparkles} from "lucide-react"
import {Button} from "@/components/ui/button"
import {useTheme} from "next-themes"
import ParticleContainer from "@/components/ui/particle-container"
import AnimatedDivider from "@/components/ui/animated-divider"
import TypingEffect from "@/components/ui/typing-effect"
import MagneticButton from "@/components/ui/magnetic-button"
import ThreeDCard from "@/components/ui/3d-card"
export default function FinalSection() {
const containerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const cardsRef = useRef<HTMLDivElement>(null)
const ctaRef = useRef<HTMLDivElement>(null)
const isInView = useInView(containerRef, {once: true, amount: 0.2})
const {theme} = useTheme()
const [gsapLoaded, setGsapLoaded] = useState(false)
const [mousePosition, setMousePosition] = useState({x: 0, y: 0})
const {scrollYProgress} = useScroll({
target: containerRef,
offset: ["start end", "end start"],
})
const y = useTransform(scrollYProgress, [0, 1], ["0%", "20%"])
const opacity = useTransform(scrollYProgress, [0.8, 1], [1, 0])
// Handle mouse movement for interactive effects
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
setMousePosition({
x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
y: ((e.clientY - rect.top) / rect.height) * 2 - 1,
})
}
window.addEventListener("mousemove", handleMouseMove)
return () => window.removeEventListener("mousemove", handleMouseMove)
}, [])
// Load GSAP and create advanced animations
useEffect(() => {
if (!isInView || gsapLoaded) return
const loadGsapAndAnimate = async () => {
try {
// Dynamically import GSAP and plugins
const {gsap} = await import("gsap")
const {ScrollTrigger} = await import("gsap/ScrollTrigger")
const {TextPlugin} = await import("gsap/TextPlugin")
const {MotionPathPlugin} = await import("gsap/MotionPathPlugin")
const {CustomEase} = await import("gsap/CustomEase")
// Register plugins
gsap.registerPlugin(ScrollTrigger, TextPlugin, MotionPathPlugin, CustomEase)
if (!containerRef.current) return
// Create a master timeline
const masterTl = gsap.timeline({
defaults: {ease: "power3.out"},
onComplete: () => console.log("All animations complete"),
})
// 1. Animate stars with staggered entrance and continuous rotation
if (containerRef.current.querySelectorAll(".star").length > 0) {
const starsTl = gsap.timeline()
// Initial entrance animation
starsTl.fromTo(
".star",
{
scale: 0,
opacity: 0,
rotation: -30,
y: -50,
},
{
scale: 1,
opacity: 1,
rotation: 0,
y: 0,
duration: 1.2,
stagger: 0.1,
ease: "elastic.out(1, 0.5)",
},
)
// Add continuous floating and twinkling animations
gsap.to(".star", {
y: "random(-15, 15)",
rotation: "random(-15, 15)",
scale: "random(0.9, 1.1)",
opacity: "random(0.7, 1)",
duration: "random(2, 4)",
repeat: -1,
yoyo: true,
ease: "sine.inOut",
stagger: {
each: 0.2,
from: "random",
},
})
masterTl.add(starsTl, 0)
}
// 2. Create an impressive text reveal for the thank you message
if (textRef.current) {
const textTl = gsap.timeline()
// Split text animation
const titleElement = textRef.current.querySelector("h2")
if (titleElement) {
// First clear the text content but save it
const originalText = titleElement.textContent || "感谢您的浏览"
titleElement.innerHTML = ""
// Create wrapper spans for each character
const wrappedText = originalText
.split("")
.map((char) => `<span class="thank-you-char relative inline-block">${char}</span>`)
.join("")
titleElement.innerHTML = wrappedText
// Animate each character
textTl.fromTo(
".thank-you-char",
{
opacity: 0,
y: 100,
rotationX: 90,
scale: 0.5,
},
{
opacity: 1,
y: 0,
rotationX: 0,
scale: 1,
duration: 1,
stagger: 0.08,
ease: "back.out(1.7)",
},
)
// Add a highlight effect that moves across the text
textTl.fromTo(
".thank-you-char",
{color: "currentColor"},
{
color: theme === "dark" ? "#60a5fa" : "#3b82f6",
stagger: 0.05,
duration: 0.3,
repeat: 1,
yoyo: true,
ease: "sine.inOut",
},
"-=0.5",
)
}
masterTl.add(textTl, 0.5)
}
// 3. Create an SVG path animation
if (svgRef.current) {
const svgTl = gsap.timeline()
const paths = svgRef.current.querySelectorAll("path")
// Draw each path
svgTl.fromTo(
paths,
{
strokeDasharray: (i, target) => target.getTotalLength(),
strokeDashoffset: (i, target) => target.getTotalLength(),
},
{
strokeDashoffset: 0,
duration: 1.5,
stagger: 0.2,
ease: "power2.inOut",
},
)
// Fill paths after drawing
svgTl.to(
paths,
{
fill: (i) =>
i % 2 === 0 ? (theme === "dark" ? "#60a5fa" : "#3b82f6") : theme === "dark" ? "#a78bfa" : "#8b5cf6",
duration: 0.5,
stagger: 0.1,
},
"-=0.5",
)
masterTl.add(svgTl, 1)
}
// 4. Animate the cards with 3D effects
if (cardsRef.current) {
const cardsTl = gsap.timeline()
const cards = cardsRef.current.querySelectorAll(".card-item")
// Staggered entrance with 3D rotation
cardsTl.fromTo(
cards,
{
opacity: 0,
y: 100,
rotationY: 45,
rotationX: 20,
z: -100,
},
{
opacity: 1,
y: 0,
rotationY: 0,
rotationX: 0,
z: 0,
duration: 1.2,
stagger: 0.15,
ease: "power4.out",
},
)
// Add hover effect to cards
cards.forEach((card) => {
card.addEventListener("mouseenter", () => {
gsap.to(card, {
y: -15,
rotationY: 5,
rotationX: -5,
scale: 1.05,
boxShadow: "0 30px 30px rgba(0,0,0,0.2)",
duration: 0.4,
ease: "power2.out",
})
})
card.addEventListener("mouseleave", () => {
gsap.to(card, {
y: 0,
rotationY: 0,
rotationX: 0,
scale: 1,
boxShadow: "0 10px 20px rgba(0,0,0,0.1)",
duration: 0.4,
ease: "power2.out",
})
})
})
masterTl.add(cardsTl, 1.5)
}
// 5. Animate the CTA section
if (ctaRef.current) {
const ctaTl = gsap.timeline()
// Heading and paragraph reveal
ctaTl.fromTo(
ctaRef.current.querySelectorAll("h3, p"),
{
opacity: 0,
y: 30,
},
{
opacity: 1,
y: 0,
duration: 0.8,
stagger: 0.2,
ease: "power2.out",
},
)
// Button reveal with bounce
ctaTl.fromTo(
ctaRef.current.querySelectorAll(".social-link"),
{
opacity: 0,
scale: 0,
},
{
opacity: 1,
scale: 1,
duration: 0.6,
stagger: 0.2,
ease: "back.out(1.7)",
},
"-=0.4",
)
// Add pulse animation to primary button
ctaTl.to(
ctaRef.current.querySelector(".social-link:first-child"),
{
boxShadow: "0 0 0 8px rgba(59, 130, 246, 0.2)",
repeat: 3,
yoyo: true,
duration: 0.8,
ease: "sine.inOut",
},
"+=0.5",
)
masterTl.add(ctaTl, 2)
}
// 6. Final heart animation
const heartTl = gsap.timeline()
heartTl.fromTo(
".heart-icon",
{
scale: 0,
opacity: 0,
},
{
scale: 1,
opacity: 1,
duration: 0.6,
ease: "back.out(1.7)",
},
)
// Pulsing heart animation
heartTl.to(".heart-icon", {
scale: 1.2,
repeat: -1,
yoyo: true,
duration: 0.8,
ease: "sine.inOut",
})
masterTl.add(heartTl, 2.5)
// 7. Create floating particles that follow mouse movement
const createFloatingParticle = () => {
const particle = document.createElement("div")
particle.className = "absolute w-2 h-2 rounded-full pointer-events-none"
particle.style.backgroundColor = theme === "dark" ? "#60a5fa" : "#3b82f6"
particle.style.opacity = "0"
containerRef.current?.appendChild(particle)
// Random position near mouse
const x = mousePosition.x * 100 + Math.random() * 100 - 50
const y = mousePosition.y * 100 + Math.random() * 100 - 50
gsap.set(particle, {x, y, opacity: 0, scale: 0})
// Animate particle
gsap.to(particle, {
x: x + (Math.random() - 0.5) * 200,
y: y - Math.random() * 200,
opacity: 0.7,
scale: Math.random() * 2 + 0.5,
duration: Math.random() * 2 + 1,
ease: "power1.out",
onComplete: () => {
gsap.to(particle, {
opacity: 0,
duration: 0.5,
onComplete: () => {
if (particle.parentNode) {
particle.parentNode.removeChild(particle)
}
},
})
},
})
}
// Create particles on interval
const particleInterval = setInterval(() => {
if (isInView && containerRef.current) {
createFloatingParticle()
}
}, 300)
// Clean up interval
setTimeout(() => {
clearInterval(particleInterval)
}, 10000) // Stop creating particles after 10 seconds
setGsapLoaded(true)
console.log("GSAP animations initialized successfully")
} catch (error) {
console.error("Failed to load GSAP or initialize animations:", error)
}
}
// Delay loading GSAP to ensure DOM is fully rendered
const timer = setTimeout(() => {
loadGsapAndAnimate()
}, 500)
return () => clearTimeout(timer)
}, [isInView, gsapLoaded, theme, mousePosition])
return (
<section
id="final"
ref={containerRef}
className="relative min-h-screen py-24 md:py-32 overflow-hidden perspective-[1000px]"
>
<ParticleContainer
className="absolute inset-0"
particleCount={40}
particleSize={[1, 3]}
particleLife={[8, 15]}
interactive={true}
/>
<div className="absolute inset-0">
<div className="container px-4 mx-auto relative z-10">
<motion.div style={{y, opacity}} className="flex flex-col items-center justify-center">
{/* Stars animation */}
<div className="flex items-center justify-center mb-6 relative h-20">
<div className="star h-8 w-8 text-yellow-500 mx-1 absolute">
<Star className="h-full w-full fill-yellow-500"/>
</div>
<div className="star h-10 w-10 text-yellow-500 mx-1 absolute"
style={{left: "calc(50% - 30px)"}}>
<Star className="h-full w-full fill-yellow-500"/>
</div>
<div className="star h-12 w-12 text-yellow-500 mx-1 absolute"
style={{left: "calc(50% - 6px)"}}>
<Star className="h-full w-full fill-yellow-500"/>
</div>
<div className="star h-10 w-10 text-yellow-500 mx-1 absolute"
style={{left: "calc(50% + 20px)"}}>
<Star className="h-full w-full fill-yellow-500"/>
</div>
<div className="star h-8 w-8 text-yellow-500 mx-1 absolute"
style={{left: "calc(50% + 40px)"}}>
<Star className="h-full w-full fill-yellow-500"/>
</div>
<div
className="star h-6 w-6 text-yellow-500 mx-1 absolute"
style={{left: "calc(50% - 50px)", top: "5px"}}
>
<Sparkles className="h-full w-full text-yellow-500"/>
</div>
<div
className="star h-6 w-6 text-yellow-500 mx-1 absolute"
style={{left: "calc(50% + 60px)", top: "10px"}}
>
<Sparkles className="h-full w-full text-yellow-500"/>
</div>
</div>
{/* Thank you text with character animation */}
<div ref={textRef} className="mb-6 perspective-[1000px]">
<h2 className="text-4xl md:text-6xl font-bold text-center mb-6 [transform-style:preserve-3d]">
</h2>
</div>
{/* SVG decoration */}
<div className="relative w-full max-w-md h-20 mb-8">
<svg ref={svgRef} viewBox="0 0 500 100" className="w-full h-full"
preserveAspectRatio="xMidYMid meet">
<path
d="M20,50 C100,20 150,80 250,50 C350,20 400,80 480,50"
stroke={theme === "dark" ? "#60a5fa" : "#3b82f6"}
strokeWidth="3"
fill="none"
strokeLinecap="round"
/>
<path
d="M20,70 C100,40 150,100 250,70 C350,40 400,100 480,70"
stroke={theme === "dark" ? "#a78bfa" : "#8b5cf6"}
strokeWidth="3"
fill="none"
strokeLinecap="round"
/>
<path
d="M250,10 L250,90"
stroke={theme === "dark" ? "#f472b6" : "#ec4899"}
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeDasharray="5,5"
/>
<circle
cx="250"
cy="50"
r="8"
stroke={theme === "dark" ? "#f472b6" : "#ec4899"}
strokeWidth="2"
fill="none"
/>
</svg>
</div>
<motion.div
initial={{opacity: 0, y: 20}}
animate={isInView ? {opacity: 1, y: 0} : {}}
transition={{duration: 0.8, delay: 0.6}}
>
<TypingEffect
text={["希望您喜欢我的个人网站", "期待与您合作", "随时联系我"]}
className="text-xl md:text-2xl text-center text-muted-foreground mb-8"
typingSpeed={70}
repeat={true}
/>
</motion.div>
<AnimatedDivider width="200px" height="2px" pattern="wave" className="mb-12" delay={0.7}/>
{/* Cards with 3D effect */}
<div
ref={cardsRef}
className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16 max-w-4xl mx-auto [transform-style:preserve-3d]"
>
<ThreeDCard
className="card-item p-6 rounded-xl bg-card/80 backdrop-blur-sm border border-border/50 text-center">
<div className="flex flex-col items-center">
<div
className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Mail className="h-6 w-6 text-primary"/>
</div>
<h3 className="text-xl font-semibold mb-2"></h3>
<p className="text-muted-foreground mb-4"></p>
<Button asChild variant="outline" size="sm">
<a href="mailto:grtsinry43@outlook.com" className="flex items-center gap-2">
<ArrowRight className="h-4 w-4"/>
</a>
</Button>
</div>
</ThreeDCard>
<ThreeDCard
className="card-item p-6 rounded-xl bg-card/80 backdrop-blur-sm border border-border/50 text-center">
<div className="flex flex-col items-center">
<div
className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Github className="h-6 w-6 text-primary"/>
</div>
<h3 className="text-xl font-semibold mb-2"></h3>
<p className="text-muted-foreground mb-4">访GitHub查看更多项目和代码</p>
<Button asChild variant="outline" size="sm">
<a
href="https://github.com/grtsinry43"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
GitHub <ArrowRight className="h-4 w-4"/>
</a>
</Button>
</div>
</ThreeDCard>
<ThreeDCard
className="card-item p-6 rounded-xl bg-card/80 backdrop-blur-sm border border-border/50 text-center">
<div className="flex flex-col items-center">
<div
className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<ExternalLink className="h-6 w-6 text-primary"/>
</div>
<h3 className="text-xl font-semibold mb-2">访</h3>
<p className="text-muted-foreground mb-4"></p>
<Button asChild variant="outline" size="sm">
<a
href="https://blog.grtsinry43.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4"/>
</a>
</Button>
</div>
</ThreeDCard>
</div>
{/* CTA section with advanced animations */}
<div ref={ctaRef} className="text-center mb-12 relative">
<div className="absolute inset-0 -z-10 opacity-30 blur-xl">
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3/4 h-3/4 rounded-full bg-gradient-to-r from-blue-500/30 to-purple-500/30"></div>
</div>
<h3 className="text-2xl font-semibold mb-4"></h3>
<p className="text-muted-foreground mb-6 max-w-xl mx-auto">
</p>
<div className="flex flex-wrap justify-center gap-4">
<MagneticButton strength={15}>
<Button asChild size="lg" className="social-link relative overflow-hidden group">
<a href="#contact" className="flex items-center gap-2">
<Mail className="h-5 w-5 transition-transform group-hover:scale-110"/>
<span className="relative z-10"></span>
<span
className="absolute inset-0 bg-gradient-to-r from-blue-600 to-blue-500 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</a>
</Button>
</MagneticButton>
<MagneticButton strength={15}>
<Button asChild variant="outline" size="lg"
className="social-link relative overflow-hidden group">
<a href="#" className="flex items-center gap-2">
<Download className="h-5 w-5 transition-transform group-hover:rotate-12"/>
<span></span>
<span
className="absolute inset-0 bg-gradient-to-r from-blue-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</a>
</Button>
</MagneticButton>
</div>
</div>
<motion.div
initial={{opacity: 0, y: 20}}
animate={isInView ? {opacity: 1, y: 0} : {}}
transition={{duration: 0.8, delay: 1.2}}
className="flex items-center justify-center"
>
<motion.div
className="flex items-center text-muted-foreground"
whileHover={{scale: 1.05}}
transition={{type: "spring", stiffness: 400, damping: 10}}
>
<span></span>
<Heart className="heart-icon h-5 w-5 text-red-500 mx-2 inline-block"/>
<span></span>
</motion.div>
</motion.div>
{/* Interactive elements that respond to mouse movement */}
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute w-40 h-40 rounded-full bg-gradient-to-r from-blue-500/5 to-purple-500/5 blur-3xl"
style={{
left: `calc(50% + ${mousePosition.x * 100}px)`,
top: `calc(50% + ${mousePosition.y * 100}px)`,
transition: "left 0.3s ease-out, top 0.3s ease-out",
}}
></div>
</div>
</motion.div>
</div>
</div>
</section>
)
}

View File

@ -2,17 +2,18 @@
import {useRef, useEffect, useState} from "react"
import {AnimatePresence, motion, useScroll, useTransform} from "framer-motion"
import gsap from "gsap"
import {ScrollTrigger} from "gsap/ScrollTrigger"
import {TextPlugin} from "gsap/TextPlugin"
import {Github, Mail, ExternalLink, Code, Heart, Coffee} from "lucide-react"
import {Button} from "@/components/ui/button"
import {useTheme} from "next-themes"
import dynamic from "next/dynamic";
import dynamic from "next/dynamic"
import MagneticButton from "@/components/ui/magnetic-button"
import TextRevealEffect from "@/components/ui/text-reveal-effect"
import AnimatedGradientText from "@/components/ui/animated-gradient-text"
import FloatingIcons from "@/components/ui/floating-icons"
const RandomItems = dynamic(() => (import('@/components/ui/random-items')), {
const RandomItems = dynamic(() => import("@/components/ui/random-items"), {
ssr: false,
});
})
export default function GsapPersonalIntro() {
const containerRef = useRef<HTMLDivElement>(null)
@ -28,7 +29,7 @@ export default function GsapPersonalIntro() {
} else {
setIsDark(false)
}
}, [resolvedTheme]);
}, [resolvedTheme])
const {scrollYProgress} = useScroll({
target: containerRef,
@ -40,82 +41,130 @@ export default function GsapPersonalIntro() {
const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0])
useEffect(() => {
gsap.registerPlugin(ScrollTrigger, TextPlugin);
const loadGsap = async () => {
try {
const {gsap} = await import("gsap")
const {ScrollTrigger} = await import("gsap/ScrollTrigger")
const {TextPlugin} = await import("gsap/TextPlugin")
if (!containerRef.current) return;
gsap.registerPlugin(ScrollTrigger, TextPlugin)
const container = containerRef.current;
if (!containerRef.current) return
// 清理所有 ScrollTrigger 实例
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
// Clean up any existing ScrollTrigger instances
ScrollTrigger.getAll().forEach((trigger) => trigger.kill())
const tl = gsap.timeline({
scrollTrigger: {
trigger: container,
start: "top 80%",
end: "bottom 20%",
toggleActions: "play none none reverse",
},
});
const container = containerRef.current
if (textRef.current) {
const headings = textRef.current.querySelectorAll("h3, h4");
const paragraphs = textRef.current.querySelectorAll("p");
const buttons = textRef.current.querySelectorAll("button, a");
const icons = textRef.current.querySelectorAll(".icon-item");
const tl = gsap.timeline({
scrollTrigger: {
trigger: container,
start: "top 80%",
end: "bottom 20%",
toggleActions: "play none none reverse",
},
})
tl.from(headings, {opacity: 0, y: 50, duration: 0.8, stagger: 0.2, ease: "power3.out"})
.from(paragraphs, {opacity: 0, y: 30, duration: 0.6, stagger: 0.2, ease: "power2.out"}, "-=0.4")
.from(buttons, {
opacity: 0,
y: 20,
scale: 0.9,
duration: 0.5,
stagger: 0.1,
ease: "back.out(1.7)"
}, "-=0.3")
.from(icons, {
opacity: 0,
scale: 0,
rotation: -30,
duration: 0.6,
stagger: 0.1,
ease: "back.out(2)"
}, "-=0.4");
if (textRef.current) {
const headings = textRef.current.querySelectorAll("h3, h4")
const paragraphs = textRef.current.querySelectorAll("p")
const buttons = textRef.current.querySelectorAll("button, a")
const icons = textRef.current.querySelectorAll(".icon-item")
tl.from(headings, {
opacity: 0,
y: 50,
duration: 0.8,
stagger: 0.2,
ease: "power3.out",
})
.from(
paragraphs,
{
opacity: 0,
y: 30,
duration: 0.6,
stagger: 0.2,
ease: "power2.out",
},
"-=0.4",
)
.from(
buttons,
{
opacity: 0,
y: 20,
scale: 0.9,
duration: 0.5,
stagger: 0.1,
ease: "back.out(1.7)",
},
"-=0.3",
)
.from(
icons,
{
opacity: 0,
scale: 0,
rotation: -30,
duration: 0.6,
stagger: 0.1,
ease: "back.out(2)",
},
"-=0.4",
)
}
if (imageRef.current) {
const shapes = imageRef.current.querySelectorAll(".shape")
const profileImage = imageRef.current.querySelector(".profile-image")
// Add a subtle floating animation to the profile image
gsap.to(profileImage, {
y: 10,
duration: 2,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
})
// Animate shapes with staggered entrances
tl.from(
shapes,
{
opacity: 0,
scale: 0,
rotation: -60,
transformOrigin: "center",
duration: 0.8,
stagger: 0.1,
ease: "back.out(2)",
},
"-=0.8",
)
// Add continuous rotation to shapes
shapes.forEach((shape, index) => {
gsap.to(shape, {
rotation: index % 2 === 0 ? "+=360" : "-=360",
duration: 15 + index * 5,
repeat: -1,
ease: "linear",
})
})
}
return () => {
// Clean up all ScrollTrigger instances
ScrollTrigger.getAll().forEach((trigger) => trigger.kill())
}
} catch (error) {
console.error("Failed to load GSAP:", error)
}
}
if (imageRef.current) {
const shapes = imageRef.current.querySelectorAll(".shape");
const particles = imageRef.current.querySelectorAll(".particle");
tl.from(shapes, {
opacity: 0,
scale: 0,
rotation: -60,
transformOrigin: "center",
duration: 0.8,
stagger: 0.1,
ease: "back.out(2)"
}, "-=0.8");
particles.forEach((particle) => {
gsap.to(particle, {
y: "random(-30, 30)",
x: "random(-30, 30)",
rotation: "random(-360, 360)",
duration: "random(3, 6)",
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
});
}
return () => {
// 确保清理所有 ScrollTrigger 实例
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
};
}, [theme, resolvedTheme, isDark]);
loadGsap()
}, [theme, resolvedTheme])
const words = ["全栈开发者", "设计者", "创造者"]
const [currentWord, setCurrentWord] = useState(0)
@ -127,22 +176,58 @@ export default function GsapPersonalIntro() {
return () => clearInterval(interval)
}, [])
// Icons for floating animation
const floatingIcons = [
<Code key="code" className="h-6 w-6 text-blue-500"/>,
<Heart key="heart" className="h-6 w-6 text-pink-500"/>,
<Coffee key="coffee" className="h-6 w-6 text-amber-500"/>,
<Github key="github" className="h-6 w-6 text-purple-500"/>,
<Mail key="mail" className="h-6 w-6 text-green-500"/>,
]
return (
<motion.div
ref={containerRef}
className="relative py-24 md:py-32 overflow-hidden"
style={{y, scale, opacity}}
>
<motion.div ref={containerRef} className="relative py-24 md:py-32 overflow-hidden" style={{y, scale, opacity}}>
{/* Background elements */}
<div className="absolute inset-0 -z-10">
<div
className="absolute inset-0 bg-gradient-to-b from-background via-background/80 to-background opacity-20"/>
<div className="absolute top-0 left-0 w-full h-full overflow-hidden">
<div className="parallax absolute top-10 left-1/4 w-64 h-64 rounded-full bg-primary/5 blur-3xl"/>
<div
className="parallax absolute bottom-20 right-1/5 w-96 h-96 rounded-full bg-blue-500/5 blur-3xl"/>
<div
className="parallax absolute top-1/3 right-1/4 w-80 h-80 rounded-full bg-emerald-500/5 blur-3xl"/>
<motion.div
className="parallax absolute top-10 left-1/4 w-64 h-64 rounded-full bg-primary/5 blur-3xl"
animate={{
x: [0, 20, 0],
y: [0, -30, 0],
}}
transition={{
duration: 15,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
<motion.div
className="parallax absolute bottom-20 right-1/5 w-96 h-96 rounded-full bg-blue-500/5 blur-3xl"
animate={{
x: [0, -40, 0],
y: [0, 20, 0],
}}
transition={{
duration: 20,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
<motion.div
className="parallax absolute top-1/3 right-1/4 w-80 h-80 rounded-full bg-emerald-500/5 blur-3xl"
animate={{
x: [0, 30, 0],
y: [0, 40, 0],
}}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
</div>
</div>
@ -150,10 +235,18 @@ export default function GsapPersonalIntro() {
<div className="grid grid-cols-1 mt-12 lg:grid-cols-2 lg:mt-0 gap-12 items-center">
<div ref={textRef} className="space-y-8">
<div>
<h3 className="text-3xl md:text-4xl font-bold tracking-tight mb-4">
<p className="text-primary text-7xl">grtsinry43</p>
</h3>
<h4 className="text-2xl md:text-3xl font-semibold text-foreground/80">
<TextRevealEffect
text="你好,我是"
className="text-3xl md:text-4xl font-bold tracking-tight mb-4"
delay={0.2}
/>
<AnimatedGradientText
text="grtsinry43"
className="text-7xl font-bold tracking-tight"
gradient="from-blue-600 via-purple-600 to-blue-600"
delay={0.5}
/>
<h4 className="text-2xl md:text-3xl font-semibold text-foreground/80 mt-4">
{" "}
<AnimatePresence mode="wait">
<motion.span
@ -176,59 +269,85 @@ export default function GsapPersonalIntro() {
</p>
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2 icon-item">
<motion.div
className="flex items-center gap-2 icon-item"
whileHover={{scale: 1.05, x: 5}}
transition={{type: "spring", stiffness: 400, damping: 10}}
>
<div className="p-2 rounded-full bg-primary/10">
<Code className="h-5 w-5 text-primary"/>
</div>
<span></span>
</div>
<div className="flex items-center gap-2 icon-item">
</motion.div>
<motion.div
className="flex items-center gap-2 icon-item"
whileHover={{scale: 1.05, x: 5}}
transition={{type: "spring", stiffness: 400, damping: 10}}
>
<div className="p-2 rounded-full bg-pink-500/10">
<Heart className="h-5 w-5 text-pink-500"/>
</div>
<span></span>
</div>
<div className="flex items-center gap-2 icon-item">
</motion.div>
<motion.div
className="flex items-center gap-2 icon-item"
whileHover={{scale: 1.05, x: 5}}
transition={{type: "spring", stiffness: 400, damping: 10}}
>
<div className="p-2 rounded-full bg-amber-500/10">
<Coffee className="h-5 w-5 text-amber-500"/>
</div>
<span></span>
</div>
</motion.div>
</div>
<div className="flex flex-wrap gap-4">
<Button asChild variant="default" className="rounded-full">
<a
href="https://github.com/grtsinry43"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<Github className="h-5 w-5"/>
GitHub
</a>
</Button>
<Button asChild variant="outline" className="rounded-full">
<a href="mailto:grtsinry43@outlook.com" className="flex items-center gap-2">
<Mail className="h-5 w-5"/>
</a>
</Button>
<Button asChild variant="ghost" className="rounded-full">
<a
href="https://blog.grtsinry43.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-5 w-5"/>
</a>
</Button>
<div className="flex flex-wrap gap-4 ml-2">
<MagneticButton strength={20}>
<Button asChild variant="default" className="rounded-full">
<a
href="https://github.com/grtsinry43"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<Github className="h-5 w-5"/>
GitHub
</a>
</Button>
</MagneticButton>
<MagneticButton strength={20}>
<Button asChild variant="outline" className="rounded-full">
<a href="mailto:grtsinry43@outlook.com" className="flex items-center gap-2">
<Mail className="h-5 w-5"/>
</a>
</Button>
</MagneticButton>
<MagneticButton strength={20}>
<Button asChild variant="ghost" className="rounded-full">
<a
href="https://blog.grtsinry43.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-5 w-5"/>
</a>
</Button>
</MagneticButton>
</div>
</div>
<div ref={imageRef} className="relative">
<div className="relative z-10 mx-auto max-w-md">
<div className="profile-image relative rounded-2xl overflow-hidden">
<motion.div
className="profile-image relative rounded-2xl"
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.8, delay: 0.3}}
whileHover={{scale: 1.03}}
>
<img
src="https://dogeoss.grtsinry43.com/img/author-removebg.png"
alt="grtsinry43"
@ -239,19 +358,16 @@ export default function GsapPersonalIntro() {
<div
className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent"/>
)}
<div
className="absolute bottom-0 left-0 right-0 p-6 transform translate-y-full hover:translate-y-0 transition-transform duration-300">
<h4 className="text-xl font-bold text-white mb-2">grtsinry43</h4>
<p className="text-white/80"> & </p>
</div>
</div>
</motion.div>
<FloatingIcons icons={floatingIcons} className="absolute inset-0 z-20 pointer-events-none"/>
<div
className="shape absolute -top-6 -right-6 w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-500 rotate-12 shadow-lg"/>
<div
className="shape absolute -bottom-8 -left-8 w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-green-500 shadow-lg"/>
<div
className="shape absolute top-1/2 -right-12 w-10 h-10 rounded-md bg-gradient-to-br from-amber-500 to-orange-500 rotate-45 shadow-lg"/>
<RandomItems/>
</div>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full -z-10">
@ -263,4 +379,4 @@ export default function GsapPersonalIntro() {
</div>
</motion.div>
)
}
}

View File

@ -3,13 +3,16 @@
import {useRef, useEffect, useState} from "react"
import {motion} from "framer-motion"
import {useTheme} from "next-themes"
import AnimatedCounter from "@/components/ui/animated-counter"
import ThreeDCard from "@/components/ui/3d-card"
export default function GsapSkillsTree() {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const {theme} = useTheme()
const [activeSkill, setActiveSkill] = useState<string | null>(null) // 状态已声明,但更新逻辑未在原始代码中提供
const [activeSkill, setActiveSkill] = useState<string | null>(null)
const [isInView, setIsInView] = useState(false)
const [hoveredGroup, setHoveredGroup] = useState<string | null>(null)
// 技能数据
const skills = [
@ -38,9 +41,9 @@ export default function GsapSkillsTree() {
// 技能分组
const skillGroups = [
{id: "web", name: "Web开发", color: "from-blue-500 to-violet-500"},
{id: "backend", name: "后端开发", color: "from-green-500 to-emerald-500"},
{id: "android", name: "Android开发", color: "from-amber-500 to-orange-500"},
{id: "web", name: "Web开发", color: "from-blue-500 to-violet-500", icon: "🌐"},
{id: "backend", name: "后端开发", color: "from-green-500 to-emerald-500", icon: "🖥️"},
{id: "android", name: "Android开发", color: "from-amber-500 to-orange-500", icon: "📱"},
]
useEffect(() => {
@ -48,8 +51,6 @@ export default function GsapSkillsTree() {
(entries) => {
if (entries[0].isIntersecting) {
setIsInView(true)
// 可选:确保只触发一次
if (containerRef.current) observer.unobserve(containerRef.current);
}
},
{threshold: 0.2},
@ -67,52 +68,50 @@ export default function GsapSkillsTree() {
}, [])
useEffect(() => {
if (!isInView) return;
if (!isInView) return
let animationFrameId: number;
let autoRotateTimeout: NodeJS.Timeout;
let resizeListener: () => void;
let mouseMoveListener: (e: MouseEvent) => void;
let animationFrameId: number
let autoRotateTimeout: NodeJS.Timeout
let resizeListener: () => void
let mouseMoveListener: (e: MouseEvent) => void
const loadGsapAndAnimate = async () => {
try {
const gsapModule = await import("gsap")
const ScrollTriggerModule = await import("gsap/ScrollTrigger")
const gsap = gsapModule.default
const ScrollTrigger = ScrollTriggerModule.default
// Import GSAP dynamically
const {gsap} = await import("gsap")
const {ScrollTrigger} = await import("gsap/ScrollTrigger")
gsap.registerPlugin(ScrollTrigger)
if (!canvasRef.current) return;
if (!canvasRef.current) return
const canvas = canvasRef.current
const ctx = canvas.getContext("2d")
if (!ctx) return;
if (!ctx) return
const dpr = window.devicePixelRatio || 1;
let cssWidth = 0;
let cssHeight = 0;
const dpr = window.devicePixelRatio || 1
let cssWidth = 0
let cssHeight = 0
const setCanvasSize = () => {
const rect = canvas.getBoundingClientRect();
cssWidth = rect.width;
cssHeight = rect.height;
const rect = canvas.getBoundingClientRect()
cssWidth = rect.width
cssHeight = rect.height
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
canvas.width = cssWidth * dpr
canvas.height = cssHeight * dpr
ctx.resetTransform();
ctx.scale(dpr, dpr);
};
ctx.resetTransform()
ctx.scale(dpr, dpr)
}
setCanvasSize(); // Initial size
resizeListener = setCanvasSize; // Store for cleanup
window.addEventListener("resize", resizeListener);
setCanvasSize() // Initial size
resizeListener = setCanvasSize // Store for cleanup
window.addEventListener("resize", resizeListener)
const modelSpaceRadius = 100; // 抽象的模型空间半径
const modelSpaceRadius = 100 // 抽象的模型空间半径
const skillNodes = skills.map((skill, index) => {
const phi = Math.acos(-1 + (2 * index) / skills.length);
const theta = Math.sqrt(skills.length * Math.PI) * phi;
const phi = Math.acos(-1 + (2 * index) / skills.length)
const theta = Math.sqrt(skills.length * Math.PI) * phi
return {
...skill,
x3d: modelSpaceRadius * Math.cos(theta) * Math.sin(phi),
@ -122,112 +121,148 @@ export default function GsapSkillsTree() {
y2d: 0,
scale: 0,
opacity: 0,
};
});
}
})
const rotation = {x: 0, y: 0};
const targetRotation = {x: 0, y: 0};
let autoRotate = true;
let mouseX = 0;
let mouseY = 0;
const rotation = {x: 0, y: 0}
const targetRotation = {x: 0, y: 0}
let autoRotate = true
let mouseX = 0
let mouseY = 0
mouseMoveListener = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return; // Avoid division by zero if canvas not rendered
mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return // Avoid division by zero if canvas not rendered
mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2
mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2
if (Math.abs(mouseX) > 0.05 || Math.abs(mouseY) > 0.05) { // Reduced sensitivity
autoRotate = false;
targetRotation.x = mouseY * 0.3; // Reduced rotation speed
targetRotation.y = mouseX * 0.3; // Reduced rotation speed
clearTimeout(autoRotateTimeout);
if (Math.abs(mouseX) > 0.05 || Math.abs(mouseY) > 0.05) {
// Reduced sensitivity
autoRotate = false
targetRotation.x = mouseY * 0.3 // Reduced rotation speed
targetRotation.y = mouseX * 0.3 // Reduced rotation speed
clearTimeout(autoRotateTimeout)
autoRotateTimeout = setTimeout(() => {
autoRotate = true;
}, 7000); // Longer timeout
autoRotate = true
}, 7000) // Longer timeout
}
};
canvas.addEventListener("mousemove", mouseMoveListener);
// Find active skill based on mouse position
let closestNode = null
let closestDistance = Number.POSITIVE_INFINITY
for (const node of skillNodes) {
const dx = node.x2d - e.clientX
const dy = node.y2d - e.clientY
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < 30 && distance < closestDistance) {
closestDistance = distance
closestNode = node
}
}
setActiveSkill(closestNode ? closestNode.name : null)
}
canvas.addEventListener("mousemove", mouseMoveListener)
const draw = () => {
ctx.clearRect(0, 0, cssWidth, cssHeight); // 使用CSS尺寸清空
ctx.clearRect(0, 0, cssWidth, cssHeight) // 使用CSS尺寸清空
if (autoRotate) {
targetRotation.y += 0.002; // 自动旋转速度
targetRotation.y += 0.002 // 自动旋转速度
}
rotation.x += (targetRotation.x - rotation.x) * 0.05; // 平滑过渡
rotation.y += (targetRotation.y - rotation.y) * 0.05; // 平滑过渡
rotation.x += (targetRotation.x - rotation.x) * 0.05 // 平滑过渡
rotation.y += (targetRotation.y - rotation.y) * 0.05 // 平滑过渡
const cosX = Math.cos(rotation.x);
const sinX = Math.sin(rotation.x);
const cosY = Math.cos(rotation.y);
const sinY = Math.sin(rotation.y);
const cosX = Math.cos(rotation.x)
const sinX = Math.sin(rotation.x)
const cosY = Math.cos(rotation.y)
const sinY = Math.sin(rotation.y)
const currentCssCenter = {x: cssWidth / 2, y: cssHeight / 2};
const cssSphereDisplayRadius = Math.min(cssWidth, cssHeight) * 0.35; // 球体在屏幕上的显示半径
const currentCssCenter = {x: cssWidth / 2, y: cssHeight / 2}
const cssSphereDisplayRadius = Math.min(cssWidth, cssHeight) * 0.35 // 球体在屏幕上的显示半径
skillNodes.forEach((node) => {
// 3D Rotation
const y_rotated = node.y3d * cosX - node.z3d * sinX;
const z_intermediate = node.y3d * sinX + node.z3d * cosX;
const x_final_model = node.x3d * cosY - z_intermediate * sinY;
const z_final_model = node.x3d * sinY + z_intermediate * cosY;
const y_final_model = y_rotated;
const y_rotated = node.y3d * cosX - node.z3d * sinX
const z_intermediate = node.y3d * sinX + node.z3d * cosX
const x_final_model = node.x3d * cosY - z_intermediate * sinY
const z_final_model = node.x3d * sinY + z_intermediate * cosY
const y_final_model = y_rotated
// Perspective scale and opacity
// z_final_model / modelSpaceRadius 将z值归一化到大约[-1, 1]范围
node.scale = (z_final_model / modelSpaceRadius + 2.5) / 3.5; // 调整分母和加值改变透视和大小范围
node.opacity = Math.max(0, (node.scale - 0.5) * 2); // 调整阈值使更多/更少的点可见
node.scale = (z_final_model / modelSpaceRadius + 2.5) / 3.5 // 调整分母和加值改变透视和大小范围
node.opacity = Math.max(0, (node.scale - 0.5) * 2) // 调整阈值使更多/更少的点可见
if (node.opacity > 0) {
// Project to 2D CSS coordinates
// (coord / modelRadius) * displayRadius: 将模型坐标按比例映射到显示半径
// * node.scale: 可选,应用透视缩放进一步影响位置(使远处的点更靠近中心)
node.x2d = currentCssCenter.x + (x_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale;
node.y2d = currentCssCenter.y + (y_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale;
node.x2d = currentCssCenter.x + (x_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale
node.y2d = currentCssCenter.y + (y_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale
ctx.save();
ctx.globalAlpha = node.opacity;
ctx.save()
ctx.globalAlpha = node.opacity
const nodeSize = (8 + node.level / 15) * node.scale; // 调整基础大小和等级影响因子
const nodeSize = (8 + node.level / 15) * node.scale // 调整基础大小和等级影响因子
ctx.fillStyle = node.color;
ctx.beginPath();
ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2);
ctx.fill();
// Draw glow effect
const gradient = ctx.createRadialGradient(node.x2d, node.y2d, 0, node.x2d, node.y2d, nodeSize * 2)
gradient.addColorStop(0, node.color + "80") // Semi-transparent
gradient.addColorStop(1, node.color + "00") // Transparent
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(node.x2d, node.y2d, nodeSize * 1.5, 0, Math.PI * 2)
ctx.fill()
// Draw node
ctx.fillStyle = node.color
ctx.beginPath()
ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2)
ctx.fill()
// Add highlight
ctx.fillStyle = "#ffffff"
ctx.globalAlpha = 0.3 * node.opacity
ctx.beginPath()
ctx.arc(node.x2d - nodeSize * 0.3, node.y2d - nodeSize * 0.3, nodeSize * 0.3, 0, Math.PI * 2)
ctx.fill()
// 仅当节点足够大时显示文字,避免拥挤
if (nodeSize > 5) {
ctx.font = `${Math.min(14, nodeSize * 1) * node.scale}px Arial`; // 字体大小也受透视影响
ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(node.name, node.x2d, node.y2d + nodeSize + 5 * node.scale); // 文字略微向下偏移
ctx.font = `${Math.min(14, nodeSize * 1) * node.scale}px Arial` // 字体大小也受透视影响
ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000"
ctx.globalAlpha = node.opacity
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(node.name, node.x2d, node.y2d + nodeSize + 5 * node.scale) // 文字略微向下偏移
}
ctx.restore();
ctx.restore()
}
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
})
animationFrameId = requestAnimationFrame(draw)
}
draw()
} catch (error) {
console.error("Failed to load GSAP or run animation:", error);
console.error("Failed to load GSAP or run animation:", error)
}
};
}
loadGsapAndAnimate();
loadGsapAndAnimate()
return () => {
cancelAnimationFrame(animationFrameId);
clearTimeout(autoRotateTimeout);
if (resizeListener) window.removeEventListener("resize", resizeListener);
cancelAnimationFrame(animationFrameId)
clearTimeout(autoRotateTimeout)
if (resizeListener) window.removeEventListener("resize", resizeListener)
if (canvasRef.current && mouseMoveListener) {
canvasRef.current.removeEventListener("mousemove", mouseMoveListener);
canvasRef.current.removeEventListener("mousemove", mouseMoveListener)
}
};
}, [isInView, theme, skills]); // skills 作为依赖,尽管在此处是常量
}
}, [isInView, theme, skills]) // skills 作为依赖,尽管在此处是常量
return (
<section id="skills" ref={containerRef} className="relative py-24 md:py-32 overflow-hidden bg-muted/30">
@ -281,46 +316,65 @@ export default function GsapSkillsTree() {
{/* 技能分组说明 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{skillGroups.map((group, idx) => (
<motion.div
<ThreeDCard
key={group.id}
initial={{opacity: 0, y: 30}}
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.6, delay: 0.2 + idx * 0.1}}
viewport={{once: true, amount: 0.3}}
className="bg-card/80 backdrop-blur-sm p-6 rounded-2xl border border-border/20 shadow-sm border-l-sky-200"
className={`p-6 rounded-2xl border-l-4 ${
hoveredGroup === group.id
? "border-l-primary"
: `border-l-${group.color.split(" ")[0].replace("from-", "")}`
}`}
glareColor={`rgba(${group.id === "web" ? "100, 150, 255" : group.id === "backend" ? "100, 200, 150" : "255, 150, 100"}, 0.3)`}
rotationIntensity={10}
onMouseEnter={() => setHoveredGroup(group.id)}
onMouseLeave={() => setHoveredGroup(null)}
>
<div className={`h-2 w-16 rounded-full bg-gradient-to-r ${group.color} mb-4`}></div>
<h4 className="text-xl font-semibold mb-3 text-card-foreground">{group.name}</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 mb-3">
<span className="text-2xl">{group.icon}</span>
<h4 className="text-xl font-semibold text-card-foreground">{group.name}</h4>
</div>
<div className="space-y-4">
{skills
.filter((skill) => skill.group === group.id)
.slice(0, 4) // 显示前4个
.map((skill) => (
<div key={skill.name} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{skill.name}</span>
<div className="flex items-center">
<div
className="h-1.5 w-20 md:w-24 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full bg-gradient-to-r ${group.color}`}
style={{width: `${skill.level}%`}}
/>
</div>
<span
className="text-xs text-muted-foreground/80 ml-2 w-8 text-right">{skill.level}%</span>
<div key={skill.name} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{skill.name}</span>
<span className="text-xs text-muted-foreground/80 font-medium">
<AnimatedCounter
from={0}
to={skill.level}
duration={1.5}
delay={idx * 0.2}
formatter={(value) => `${Math.round(value)}%`}
/>
</span>
</div>
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${group.color}`}
initial={{width: 0}}
whileInView={{width: `${skill.level}%`}}
transition={{duration: 1.5, delay: idx * 0.2 + 0.2}}
viewport={{once: true}}
/>
</div>
</div>
))}
{skills.filter((skill) => skill.group === group.id).length > 4 && (
<div className="text-xs text-right text-muted-foreground/70 mt-2">
<motion.div
className="text-xs text-right text-muted-foreground/70 mt-2"
whileHover={{x: 5, color: "var(--primary)"}}
>
+{skills.filter((skill) => skill.group === group.id).length - 4}
</div>
</motion.div>
)}
</div>
</motion.div>
</ThreeDCard>
))}
</div>
</div>
</section>
)
}
}

View File

@ -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<HTMLDivElement>(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<HTMLDivElement>) {
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 (
<motion.div
ref={cardRef}
className={cn("relative overflow-hidden rounded-xl transition-all duration-200", className)}
style={{
perspective: "1200px",
transformStyle: "preserve-3d",
rotateX,
rotateY,
border: `1px solid ${borderColor}`,
boxShadow: isHovered ? `0 20px 40px ${shadowColor}` : `0 10px 20px ${shadowColor}`,
}}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
{/* Background gradient */}
<div className="absolute inset-0 z-0" style={{ background: backgroundGradient }} />
{/* Glare effect */}
<motion.div
className="absolute inset-0 z-10 rounded-xl"
style={{
background: `radial-gradient(circle at ${useTransform(glareX, [0, 1], ["0%", "100%"])} ${useTransform(glareY, [0, 1], ["0%", "100%"])}, ${glareColor} 0%, rgba(255, 255, 255, 0) 80%)`,
opacity: glareOpacity,
}}
/>
{/* Content */}
<div className="relative z-20">{children}</div>
</motion.div>
)
}

View File

@ -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<HTMLSpanElement>(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 (
<span ref={nodeRef} className={className}>
{formatter(count)}
</span>
)
}

View File

@ -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<HTMLDivElement>(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 (
<div ref={ref} className={cn("overflow-hidden", className)} style={{width, height}}>
<motion.svg
width={svgWidth}
height={svgHeight}
viewBox={viewBox}
preserveAspectRatio="none"
initial={{pathLength: 0, opacity: 0}}
animate={isInView ? {pathLength: 1, opacity: 1} : {pathLength: 0, opacity: 0}}
transition={{duration: animationDuration, delay, ease: "easeInOut"}}
>
<motion.path
d={generatePath()}
stroke={color}
strokeWidth="1"
fill="none"
strokeDasharray={pathLength}
strokeDashoffset={pathLength}
animate={isInView ? {strokeDashoffset: 0} : {strokeDashoffset: pathLength}}
transition={{duration: animationDuration, delay, ease: "easeInOut"}}
/>
</motion.svg>
</div>
)
}
// Render standard divider for solid, dashed, dotted
return (
<div
ref={ref}
className={cn("overflow-hidden", className)}
style={{
width: direction === "horizontal" ? width : height,
height: direction === "horizontal" ? height : width,
}}
>
<motion.div
style={{
width: "100%",
height: "100%",
background: color,
borderStyle: pattern === "solid" ? "none" : pattern,
borderWidth: pattern === "solid" ? 0 : "1px",
borderColor: pattern === "solid" ? "transparent" : color,
transformOrigin: direction === "horizontal" ? "left center" : "center top",
scaleX: direction === "horizontal" ? 0 : 1,
scaleY: direction === "vertical" ? 0 : 1,
}}
initial={{
scaleX: direction === "horizontal" ? 0 : 1,
scaleY: direction === "vertical" ? 0 : 1,
}}
animate={
isInView
? {
scaleX: 1,
scaleY: 1,
}
: {
scaleX: direction === "horizontal" ? 0 : 1,
scaleY: direction === "vertical" ? 0 : 1,
}
}
transition={{duration: animationDuration, delay, ease: "easeInOut"}}
/>
</div>
)
}

View File

@ -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<HTMLDivElement>(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 (
<motion.div
ref={textRef}
className={cn(
"inline-block font-bold bg-clip-text text-transparent",
`bg-gradient-to-r ${gradient}`,
"bg-[length:200%_auto]",
className,
)}
style={{ backgroundPosition: "0% center" }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: delay * 0.1 }}
>
{text}
</motion.div>
)
}

View File

@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
{/* Spotlight effect */}
<motion.div
className="absolute pointer-events-none"
style={{
x: spotlightX,
y: spotlightY,
width: size,
height: size,
borderRadius: "50%",
background: color,
filter: `blur(${blur}px)`,
opacity,
transform: "translate(-50%, -50%)",
transition: `opacity ${delay}s ease`,
}}
/>
{/* Content */}
<div className="relative z-10">{children}</div>
</div>
)
}

View File

@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className={className}>
{icons.map((icon, index) => (
<motion.div
key={index}
className="floating-icon absolute"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
{icon}
</motion.div>
))}
</div>
)
}

View File

@ -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<HTMLButtonElement>(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<HTMLButtonElement>) {
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 (
<motion.button
ref={buttonRef}
className={cn(
"relative overflow-hidden transition-colors",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
style={{ x, y }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
whileTap={{ scale: 0.95 }}
disabled={disabled}
>
{children}
</motion.button>
)
}

View File

@ -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<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const particlesRef = useRef<Particle[]>([])
const animationRef = useRef<number>(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 (
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
<canvas ref={canvasRef} className="absolute inset-0 pointer-events-none z-0"/>
<div className="relative z-10">{children}</div>
</div>
)
}

View File

@ -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<HTMLDivElement>(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 (
<motion.div
ref={ref}
className={className}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
variants={getVariants()}
>
{React.Children.map(children, (child, index) => (
<motion.div variants={getVariants()} custom={index} className="scroll-reveal-child">
{child}
</motion.div>
))}
</motion.div>
)
}
// Otherwise, animate the container as a whole
return (
<motion.div
ref={ref}
className={className}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
variants={getVariants()}
>
{children}
</motion.div>
)
}

View File

@ -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<HTMLDivElement>(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 (
<Component className={className} ref={ref}>
{words.map((word, wordIndex) => (
<motion.span
key={`word-${wordIndex}`}
className="inline-block whitespace-nowrap"
variants={wordVariants}
initial="hidden"
animate={controls}
aria-hidden
>
{word.split("").map((character, charIndex) => (
<motion.span
key={`char-${charIndex}`}
className="inline-block"
variants={characterVariants}
initial="hidden"
animate={controls}
custom={wordIndex * 0.25 + charIndex}
>
{character}
</motion.span>
))}
{wordIndex < words.length - 1 && <span>&nbsp;</span>}
</motion.span>
))}
</Component>
)
}

View File

@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className={className}>
<span>{displayText}</span>
{cursor && !isComplete && (
<motion.span
style={{color: cursorColor}}
animate={{opacity: [1, 0]}}
transition={{duration: 0.8, repeat: Number.POSITIVE_INFINITY, repeatType: "reverse"}}
>
{cursorStyle}
</motion.span>
)}
</div>
)
}