feat: build resume website with MagicUI components

- 6 sections: Hero, About, Experience, Skills, Projects, Contact
- MagicUI: Globe, Particles, Meteors, AnimatedList, IconCloud, BentoGrid
- Dark mode support, scroll-triggered animations
- Static export ready for deployment
This commit is contained in:
2026-04-14 15:09:48 +08:00
parent a0207c673f
commit 6fec90ea71
28 changed files with 8547 additions and 18 deletions

View File

@@ -0,0 +1,21 @@
"use client"
import { BlurFade } from "@/components/ui/blur-fade"
export function SectionWrapper({
children,
className,
id,
}: {
children: React.ReactNode
className?: string
id?: string
}) {
return (
<section id={id} className={className}>
<BlurFade delay={0.15} inView inViewMargin="-50px">
{children}
</BlurFade>
</section>
)
}

View File

@@ -0,0 +1,92 @@
"use client"
import {
CodeIcon,
CpuIcon,
TerminalIcon,
GlobeIcon,
DatabaseIcon,
LayerIcon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { OrbitingCircles } from "@/components/ui/orbiting-circles"
import { SectionWrapper } from "@/components/section-wrapper"
const techIcons = [
{ icon: <HugeiconsIcon icon={CodeIcon} className="size-6" />, label: "Go" },
{ icon: <HugeiconsIcon icon={TerminalIcon} className="size-6" />, label: "Python" },
{ icon: <HugeiconsIcon icon={DatabaseIcon} className="size-6" />, label: "TypeScript" },
{ icon: <HugeiconsIcon icon={CpuIcon} className="size-6" />, label: "C#" },
{ icon: <HugeiconsIcon icon={LayerIcon} className="size-6" />, label: "React" },
{ icon: <HugeiconsIcon icon={GlobeIcon} className="size-6" />, label: "Django" },
]
export function About() {
return (
<SectionWrapper id="about" className="py-24">
<div className="mx-auto max-w-5xl px-6">
<div className="grid gap-12 lg:grid-cols-2 lg:items-center">
{/* 简介文字 */}
<div>
<h2 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">
</h2>
<div className="space-y-3 text-muted-foreground">
<p className="text-base">
AI AI
</p>
<p className="text-sm">
Go / Python / C# React Docker / Linux
</p>
<p className="text-sm">
AI
</p>
</div>
</div>
{/* 环绕动画 */}
<div className="relative flex items-center justify-center">
<div className="relative flex size-80 items-center justify-center">
{/* 中心文字 */}
<div className="z-10 text-center">
<div className="text-4xl font-bold">6+</div>
<div className="text-sm text-muted-foreground">
</div>
</div>
<OrbitingCircles radius={110} duration={25} iconSize={40}>
{techIcons.slice(0, 3).map((tech, i) => (
<div
key={i}
className="flex size-10 items-center justify-center rounded-full border border-border bg-background p-2"
title={tech.label}
>
{tech.icon}
</div>
))}
</OrbitingCircles>
<OrbitingCircles
radius={160}
duration={35}
reverse
iconSize={40}
>
{techIcons.slice(3).map((tech, i) => (
<div
key={i}
className="flex size-10 items-center justify-center rounded-full border border-border bg-background p-2"
title={tech.label}
>
{tech.icon}
</div>
))}
</OrbitingCircles>
</div>
</div>
</div>
</div>
</SectionWrapper>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { ShinyButton } from "@/components/ui/shiny-button"
import { SectionWrapper } from "@/components/section-wrapper"
export function Contact() {
return (
<SectionWrapper id="contact" className="py-24">
<div className="mx-auto max-w-2xl px-6 text-center">
<h2 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">
</h2>
<p className="mb-8 text-muted-foreground">
AI
</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<a href="mailto:liukersun@gmail.com">
<ShinyButton></ShinyButton>
</a>
<a href="https://github.com/LiukerSun" target="_blank" rel="noopener noreferrer">
<ShinyButton>GitHub</ShinyButton>
</a>
<a
href="https://t.me/DrJhaha"
target="_blank"
rel="noopener noreferrer"
>
<ShinyButton>Telegram</ShinyButton>
</a>
</div>
</div>
{/* 页脚 */}
<footer className="mt-24 border-t border-border pt-8 text-center text-sm text-muted-foreground">
<p>&copy; 2026 Evan Sun. </p>
</footer>
</SectionWrapper>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import {
Building03Icon,
CodeIcon,
Award02Icon,
ProjectorIcon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { AnimatedList, AnimatedListItem } from "@/components/ui/animated-list"
import { SectionWrapper } from "@/components/section-wrapper"
interface ExperienceItem {
role: string
company: string
period: string
description: string
}
const experiences: ExperienceItem[] = [
{
role: "全栈开发工程师",
company: "MakeBlock",
period: "2024.10 - 2025.4",
description:
"负责内部质检与业务系统的全栈开发,覆盖前端页面、后端接口、数据流转与部署协作。",
},
{
role: "项目经理 / 技术负责人",
company: "HIT 重庆",
period: "2024.1 - 2024.9",
description:
"担任项目管理与技术负责人,统筹技术选型与团队协作,推动项目按期高质量交付。",
},
{
role: "测试开发工程师",
company: "ByteDance字节跳动",
period: "2021.12 - 2023.3",
description:
"负责内部测试工具与自动化流程的开发,保障产品质量与交付效率。",
},
{
role: "BIM 工程师",
company: "亚厦集团",
period: "2020.8 - 2021.11",
description:
"负责建筑信息模型BIM相关工作进行 3D 建模与工程协调。",
},
]
const icons = [
<HugeiconsIcon key="makeblock" icon={Building03Icon} className="size-5" />,
<HugeiconsIcon key="hit" icon={ProjectorIcon} className="size-5" />,
<HugeiconsIcon key="bytedance" icon={Award02Icon} className="size-5" />,
<HugeiconsIcon key="yasha" icon={CodeIcon} className="size-5" />,
]
export function Experience() {
return (
<SectionWrapper id="experience" className="py-24">
<div className="mx-auto max-w-3xl px-6">
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight sm:text-4xl">
</h2>
<div className="space-y-6">
{experiences.map((exp, i) => (
<AnimatedListItem key={exp.company}>
<div className="group relative flex gap-4 rounded-xl border border-border bg-card p-6 transition-colors hover:bg-accent/50">
{/* 时间线 */}
<div className="relative flex flex-col items-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors group-hover:border-primary/50 group-hover:text-foreground">
{icons[i]}
</div>
{i < experiences.length - 1 && (
<div className="absolute top-12 h-full w-px bg-border" />
)}
</div>
{/* 内容 */}
<div className="flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{exp.role}</h3>
<span className="text-sm text-muted-foreground">
{exp.period}
</span>
</div>
<p className="text-sm text-muted-foreground">{exp.company}</p>
<p className="mt-2 text-sm text-muted-foreground">
{exp.description}
</p>
</div>
</div>
</AnimatedListItem>
))}
</div>
</div>
</SectionWrapper>
)
}

View File

@@ -0,0 +1,100 @@
"use client"
import { useMemo } from "react"
import { useTheme } from "next-themes"
import type { COBEOptions } from "cobe"
import { Globe } from "@/components/ui/globe"
import { Particles } from "@/components/ui/particles"
import { Meteors } from "@/components/ui/meteors"
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text"
import { TextAnimate } from "@/components/ui/text-animate"
export function Hero() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === "dark"
const globeConfig = useMemo((): Omit<COBEOptions, "width" | "height"> => {
const baseConfig: Omit<COBEOptions, "width" | "height"> = {
phi: 0,
theta: 0.3,
dark: isDark ? 1 : 0,
diffuse: 0.4,
mapSamples: 12000,
mapBrightness: 1.2,
devicePixelRatio: 2,
baseColor: isDark ? [0.3, 0.3, 0.3] as [number, number, number] : [1, 1, 1] as [number, number, number],
markerColor: [251 / 255, 100 / 255, 21 / 255] as [number, number, number],
glowColor: isDark ? [0.5, 0.5, 0.5] as [number, number, number] : [1, 1, 1] as [number, number, number],
markers: [
{ location: [39.9042, 116.4074], size: 0.08 },
{ location: [40.7128, -74.006], size: 0.1 },
{ location: [51.5074, -0.1278], size: 0.07 },
{ location: [35.6762, 139.6503], size: 0.06 },
],
}
return baseConfig
}, [isDark])
return (
<section className="relative flex min-h-svh items-center justify-center overflow-hidden">
{/* Background effects */}
<div className="absolute inset-0">
<Globe className="opacity-40" config={globeConfig} />
</div>
<Particles
className="absolute inset-0"
quantity={40}
color={isDark ? "#ffffff" : "#000000"}
/>
<Meteors number={12} />
{/* Content */}
<div className="relative z-10 mx-auto max-w-4xl px-6 text-center">
<AnimatedGradientText className="mb-4 text-lg font-medium tracking-wide sm:text-xl">
👋
</AnimatedGradientText>
<h1 className="mb-4 text-5xl font-bold tracking-tight sm:text-7xl md:text-8xl">
<TextAnimate
by="character"
animation="blurInUp"
delay={0.3}
once
>
Evan Sun
</TextAnimate>
</h1>
<TextAnimate
as="p"
className="mx-auto max-w-2xl text-lg text-muted-foreground sm:text-xl"
by="word"
animation="fadeIn"
delay={0.8}
once
>
/ AI
</TextAnimate>
<div className="mt-8 flex justify-center gap-4">
<a
href="#contact"
className="rounded-lg bg-foreground px-6 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-80"
>
</a>
<a
href="#projects"
className="rounded-lg border border-border px-6 py-2.5 text-sm font-medium transition-colors hover:bg-accent"
>
</a>
</div>
</div>
{/* Fade gradient at bottom */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
</section>
)
}

View File

@@ -0,0 +1,209 @@
"use client"
import {
DatabaseIcon,
CodeIcon,
RepeatIcon,
AiVideoIcon,
Chatting01Icon,
FolderOpenIcon,
TranslateIcon,
BarChartIcon,
BotIcon,
CloudUploadIcon,
FileAttachmentIcon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { BentoCard, BentoGrid } from "@/components/ui/bento-grid"
import { SectionWrapper } from "@/components/section-wrapper"
const GITHUB_BASE = "https://github.com/LiukerSun"
const projects = [
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={DatabaseIcon} {...props} />
),
name: "ERP 管理系统",
description:
"管理超过 1200 件商品 SKU覆盖库存、供应链、图片和出入库流程。Go 后端 + TypeScript 前端,整体效率提升 3-4 倍。",
href: `${GITHUB_BASE}/erp_backend`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-transparent to-purple-500/10" />
),
className: "col-span-1 lg:col-span-2",
},
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={RepeatIcon} {...props} />
),
name: "自动化交易系统",
description:
"基于 MetaTrader 5 的量化交易自动化系统,支持策略回测与实时监控。",
href: `${GITHUB_BASE}/metatrader5-quant-server-python`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-amber-500/10 via-transparent to-orange-500/10" />
),
className: "col-span-1 lg:col-span-1",
},
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={Chatting01Icon} {...props} />
),
name: "Telegram AI 翻译机器人",
description:
"集成 OpenAI 的 Telegram 机器人,支持自定义预设、多轮对话上下文管理和用户权限系统。",
href: `${GITHUB_BASE}/tg_ai_translate_bot_go`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-green-500/10 via-transparent to-teal-500/10" />
),
className: "col-span-1 lg:col-span-1",
},
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={AiVideoIcon} {...props} />
),
name: "Agent Dispatcher",
description:
"AI Agent 调度系统,智能分配任务与资源管理。",
href: `${GITHUB_BASE}/agentdispatcher`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-pink-500/10 via-transparent to-rose-500/10" />
),
className: "col-span-1 lg:col-span-1",
},
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={BarChartIcon} {...props} />
),
name: "直播数据分析",
description:
"实时直播数据抓取与分析工具。",
href: `${GITHUB_BASE}/liveDataAnalysis`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-cyan-500/10 via-transparent to-blue-500/10" />
),
className: "col-span-1 lg:col-span-1",
},
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={CodeIcon} {...props} />
),
name: "cc-cli 命令行工具",
description:
"通用命令行工具集,提升开发效率。",
href: `${GITHUB_BASE}/cc-cli`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/10 via-transparent to-indigo-500/10" />
),
className: "col-span-1 lg:col-span-1",
},
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={FileAttachmentIcon} {...props} />
),
name: "Excel JSON 转换工具",
description:
"Excel 表格转 JSON 格式的 Python 工具,支持自定义模板。",
href: `${GITHUB_BASE}/excel-json-tool`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 via-transparent to-green-500/10" />
),
className: "col-span-1 lg:col-span-1",
},
{
Icon: (props: { className?: string }) => (
<HugeiconsIcon icon={CloudUploadIcon} {...props} />
),
name: "DevTools 开发网关",
description:
"基于 Docker + Traefik + Cloudflare DNS 验证的本地开发网关系统。",
href: `${GITHUB_BASE}/DevTools`,
cta: "查看源码",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-sky-500/10 via-transparent to-blue-500/10" />
),
className: "col-span-1 lg:col-span-1",
},
]
const otherProjects = [
{
name: "OpenManage",
href: `${GITHUB_BASE}/openmanage`,
icon: <HugeiconsIcon icon={FolderOpenIcon} className="size-5" />,
},
{
name: "抖音弹幕录制",
href: `${GITHUB_BASE}/DouyinDanmu`,
icon: <HugeiconsIcon icon={BotIcon} className="size-5" />,
},
{
name: "微信机器人",
href: `${GITHUB_BASE}/weixinbot`,
icon: <HugeiconsIcon icon={Chatting01Icon} className="size-5" />,
},
{
name: "Excel 转 MySQL",
href: `${GITHUB_BASE}/excel2mysql`,
icon: <HugeiconsIcon icon={TranslateIcon} className="size-5" />,
},
{
name: "抖店 Excel",
href: `${GITHUB_BASE}/doudian_excel`,
icon: <HugeiconsIcon icon={FileAttachmentIcon} className="size-5" />,
},
{
name: "Flip Game",
href: `${GITHUB_BASE}/flipgame`,
icon: <HugeiconsIcon icon={RepeatIcon} className="size-5" />,
},
]
export function Projects() {
return (
<SectionWrapper id="projects" className="py-24">
<div className="mx-auto max-w-5xl px-6">
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight sm:text-4xl">
</h2>
<BentoGrid>
{projects.map((project) => (
<BentoCard key={project.name} {...project} />
))}
</BentoGrid>
{/* 其他开源项目 */}
<h3 className="mt-16 mb-6 text-center text-xl font-semibold">
</h3>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-6">
{otherProjects.map((item) => (
<a
key={item.name}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent/50"
>
<div className="flex size-10 items-center justify-center rounded-full border border-border bg-background text-muted-foreground">
{item.icon}
</div>
<div className="text-xs text-muted-foreground underline">
{item.name}
</div>
</a>
))}
</div>
</div>
</SectionWrapper>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { SectionWrapper } from "@/components/section-wrapper"
import { IconCloud } from "@/components/ui/icon-cloud"
const skills = [
"Go",
"Python",
"TypeScript",
"C#",
"JavaScript",
"Swift",
"Shell",
"Django",
"React",
"Docker",
"Traefik",
"MT5",
"OpenAI",
"TG Bot",
"MySQL",
"Git",
"CI/CD",
"DevOps",
"ERP",
"量化",
]
// 为每个技能生成 SVG 文本元素
function SkillIcon({ text }: { text: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40"
>
<circle cx="20" cy="20" r="18" fill="currentColor" opacity="0.1" />
<text
x="20"
y="24"
textAnchor="middle"
fontSize="8"
fill="currentColor"
fontWeight="500"
>
{text.slice(0, 5)}
</text>
</svg>
)
}
export function Skills() {
const iconElements = skills.map((skill, i) => <SkillIcon key={i} text={skill} />)
return (
<SectionWrapper id="skills" className="py-24">
<div className="mx-auto max-w-5xl px-6">
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight sm:text-4xl">
</h2>
<div className="flex flex-col items-center gap-8 lg:flex-row lg:justify-center lg:gap-16">
{/* 3D 图标云 */}
<div className="relative flex items-center justify-center">
<IconCloud icons={iconElements} />
</div>
{/* 技能分类 */}
<div className="space-y-6">
<div>
<h3 className="mb-2 font-semibold"></h3>
<div className="flex flex-wrap gap-2">
{["Go", "Python", "TypeScript", "C#", "JavaScript", "Swift"].map(
(s) => (
<span
key={s}
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
>
{s}
</span>
)
)}
</div>
</div>
<div>
<h3 className="mb-2 font-semibold"></h3>
<div className="flex flex-wrap gap-2">
{["Django", "React", "MT5", "OpenAI API", "TG Bot"].map(
(s) => (
<span
key={s}
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
>
{s}
</span>
)
)}
</div>
</div>
<div>
<h3 className="mb-2 font-semibold"></h3>
<div className="flex flex-wrap gap-2">
{["Docker", "Traefik", "MySQL", "CI/CD", "DevOps"].map((s) => (
<span
key={s}
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
>
{s}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</SectionWrapper>
)
}

View File

@@ -0,0 +1,37 @@
import { type ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
speed?: number
colorFrom?: string
colorTo?: string
}
export function AnimatedGradientText({
children,
className,
speed = 1,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
...props
}: AnimatedGradientTextProps) {
return (
<span
style={
{
"--bg-size": `${speed * 300}%`,
"--color-from": colorFrom,
"--color-to": colorTo,
} as React.CSSProperties
}
className={cn(
`animate-gradient inline bg-linear-to-r from-(--color-from) via-(--color-to) to-(--color-from) bg-size-[var(--bg-size)_100%] bg-clip-text text-transparent`,
className
)}
{...props}
>
{children}
</span>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import React, {
useEffect,
useMemo,
useState,
type ComponentPropsWithoutRef,
} from "react"
import { AnimatePresence, motion, type MotionProps } from "motion/react"
import { cn } from "@/lib/utils"
export function AnimatedListItem({ children }: { children: React.ReactNode }) {
const animations: MotionProps = {
initial: { scale: 0, opacity: 0 },
animate: { scale: 1, opacity: 1, originY: 0 },
exit: { scale: 0, opacity: 0 },
transition: { type: "spring", stiffness: 350, damping: 40 },
}
return (
<motion.div {...animations} layout className="mx-auto w-full">
{children}
</motion.div>
)
}
export interface AnimatedListProps extends ComponentPropsWithoutRef<"div"> {
children: React.ReactNode
delay?: number
}
export const AnimatedList = React.memo(
({ children, className, delay = 1000, ...props }: AnimatedListProps) => {
const [index, setIndex] = useState(0)
const childrenArray = useMemo(
() => React.Children.toArray(children),
[children]
)
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (index < childrenArray.length - 1) {
timeout = setTimeout(() => {
setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length)
}, delay)
}
return () => {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [index, delay, childrenArray.length])
const itemsToShow = useMemo(() => {
const result = childrenArray.slice(0, index + 1).reverse()
return result
}, [index, childrenArray])
return (
<div
className={cn(`flex flex-col items-center gap-4`, className)}
{...props}
>
<AnimatePresence>
{itemsToShow.map((item) => (
<AnimatedListItem key={(item as React.ReactElement).key}>
{item}
</AnimatedListItem>
))}
</AnimatePresence>
</div>
)
}
)
AnimatedList.displayName = "AnimatedList"

View File

@@ -0,0 +1,109 @@
import { type ComponentPropsWithoutRef, type ReactNode } from "react"
import { ArrowRightIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface BentoGridProps extends ComponentPropsWithoutRef<"div"> {
children: ReactNode
className?: string
}
interface BentoCardProps extends ComponentPropsWithoutRef<"div"> {
name: string
className: string
background: ReactNode
Icon: React.ElementType
description: string
href: string
cta: string
}
const BentoGrid = ({ children, className, ...props }: BentoGridProps) => {
return (
<div
className={cn(
"grid w-full auto-rows-[22rem] grid-cols-3 gap-4",
className
)}
{...props}
>
{children}
</div>
)
}
const BentoCard = ({
name,
className,
background,
Icon,
description,
href,
cta,
...props
}: BentoCardProps) => (
<div
key={name}
className={cn(
"group relative col-span-3 flex flex-col justify-between overflow-hidden rounded-xl",
// light styles
"bg-background [box-shadow:0_0_0_1px_rgba(0,0,0,.03),0_2px_4px_rgba(0,0,0,.05),0_12px_24px_rgba(0,0,0,.05)]",
// dark styles
"dark:bg-background transform-gpu dark:[box-shadow:0_-20px_80px_-20px_#ffffff1f_inset] dark:[border:1px_solid_rgba(255,255,255,.1)]",
className
)}
{...props}
>
<div>{background}</div>
<div className="p-4">
<div className="pointer-events-none z-10 flex transform-gpu flex-col gap-1 transition-all duration-300 lg:group-hover:-translate-y-10">
<Icon className="h-12 w-12 origin-left transform-gpu text-neutral-700 transition-all duration-300 ease-in-out group-hover:scale-75" />
<h3 className="text-xl font-semibold text-neutral-700 dark:text-neutral-300">
{name}
</h3>
<p className="max-w-lg text-neutral-400">{description}</p>
</div>
<div
className={cn(
"pointer-events-none flex w-full translate-y-0 transform-gpu flex-row items-center transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:hidden"
)}
>
<Button
variant="link"
asChild
size="sm"
className="pointer-events-auto p-0"
>
<a href={href} target="_blank" rel="noopener noreferrer">
{cta}
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
</a>
</Button>
</div>
</div>
<div
className={cn(
"pointer-events-none absolute bottom-0 hidden w-full translate-y-10 transform-gpu flex-row items-center p-4 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:flex"
)}
>
<Button
variant="link"
asChild
size="sm"
className="pointer-events-auto p-0"
>
<a href={href} target="_blank" rel="noopener noreferrer">
{cta}
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
</a>
</Button>
</div>
<div className="pointer-events-none absolute inset-0 transform-gpu transition-all duration-300 group-hover:bg-black/3 group-hover:dark:bg-neutral-800/10" />
</div>
)
export { BentoCard, BentoGrid }

View File

@@ -0,0 +1,94 @@
"use client"
import { useRef } from "react"
import {
AnimatePresence,
motion,
useInView,
type MotionProps,
type UseInViewOptions,
type Variants,
} from "motion/react"
type MarginType = UseInViewOptions["margin"]
interface BlurFadeProps extends MotionProps {
children: React.ReactNode
className?: string
variant?: {
hidden: { y: number }
visible: { y: number }
}
duration?: number
delay?: number
offset?: number
direction?: "up" | "down" | "left" | "right"
inView?: boolean
inViewMargin?: MarginType
blur?: string
}
const getFilter = (v: Variants[string]) =>
typeof v === "function" ? undefined : v.filter
export function BlurFade({
children,
className,
variant,
duration = 0.4,
delay = 0,
offset = 6,
direction = "down",
inView = false,
inViewMargin = "-50px",
blur = "6px",
...props
}: BlurFadeProps) {
const ref = useRef(null)
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
const isInView = !inView || inViewResult
const defaultVariants: Variants = {
hidden: {
[direction === "left" || direction === "right" ? "x" : "y"]:
direction === "right" || direction === "down" ? -offset : offset,
opacity: 0,
filter: `blur(${blur})`,
},
visible: {
[direction === "left" || direction === "right" ? "x" : "y"]: 0,
opacity: 1,
filter: `blur(0px)`,
},
}
const combinedVariants = variant ?? defaultVariants
const hiddenFilter = getFilter(combinedVariants.hidden)
const visibleFilter = getFilter(combinedVariants.visible)
const shouldTransitionFilter =
hiddenFilter != null &&
visibleFilter != null &&
hiddenFilter !== visibleFilter
return (
<AnimatePresence>
<motion.div
ref={ref}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
exit="hidden"
variants={combinedVariants}
transition={{
delay: 0.04 + delay,
duration,
ease: "easeOut",
...(shouldTransitionFilter ? { filter: { duration } } : {}),
}}
className={className}
{...props}
>
{children}
</motion.div>
</AnimatePresence>
)
}

136
components/ui/globe.tsx Normal file
View File

@@ -0,0 +1,136 @@
"use client"
import { useEffect, useRef } from "react"
import createGlobe, { type COBEOptions } from "cobe"
import { useMotionValue, useSpring } from "motion/react"
import { cn } from "@/lib/utils"
const MOVEMENT_DAMPING = 1400
const GLOBE_CONFIG: Omit<COBEOptions, "width" | "height"> = {
phi: 0,
theta: 0.3,
dark: 0,
diffuse: 0.4,
mapSamples: 16000,
mapBrightness: 1.2,
devicePixelRatio: 2,
baseColor: [1, 1, 1] as [number, number, number],
markerColor: [251 / 255, 100 / 255, 21 / 255] as [number, number, number],
glowColor: [1, 1, 1] as [number, number, number],
markers: [
{ location: [14.5995, 120.9842], size: 0.03 },
{ location: [19.076, 72.8777], size: 0.1 },
{ location: [23.8103, 90.4125], size: 0.05 },
{ location: [30.0444, 31.2357], size: 0.07 },
{ location: [39.9042, 116.4074], size: 0.08 },
{ location: [-23.5505, -46.6333], size: 0.1 },
{ location: [19.4326, -99.1332], size: 0.1 },
{ location: [40.7128, -74.006], size: 0.1 },
{ location: [34.6937, 135.5022], size: 0.05 },
{ location: [41.0082, 28.9784], size: 0.06 },
],
}
export function Globe({
className,
config = GLOBE_CONFIG,
}: {
className?: string
config?: Omit<COBEOptions, "width" | "height">
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const phiRef = useRef(0)
const widthRef = useRef(0)
const pointerInteracting = useRef<number | null>(null)
const pointerInteractionMovement = useRef(0)
const globeRef = useRef<ReturnType<typeof createGlobe> | null>(null)
const r = useMotionValue(0)
const rs = useSpring(r, {
mass: 1,
damping: 30,
stiffness: 100,
})
const updatePointerInteraction = (value: number | null) => {
pointerInteracting.current = value
if (canvasRef.current) {
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab"
}
}
const updateMovement = (clientX: number) => {
if (pointerInteracting.current !== null) {
const delta = clientX - pointerInteracting.current
pointerInteractionMovement.current = delta
r.set(r.get() + delta / MOVEMENT_DAMPING)
}
}
useEffect(() => {
const onResize = () => {
if (canvasRef.current) {
widthRef.current = canvasRef.current.offsetWidth
globeRef.current?.update({
width: widthRef.current * 2,
height: widthRef.current * 2,
})
}
}
window.addEventListener("resize", onResize)
onResize()
let animFrame: number
const render = () => {
if (!pointerInteracting.current) phiRef.current += 0.005
globeRef.current?.update({
phi: phiRef.current + rs.get(),
})
animFrame = requestAnimationFrame(render)
}
globeRef.current = createGlobe(canvasRef.current!, {
...config,
width: widthRef.current * 2,
height: widthRef.current * 2,
})
animFrame = requestAnimationFrame(render)
setTimeout(() => (canvasRef.current!.style.opacity = "1"), 0)
return () => {
cancelAnimationFrame(animFrame)
globeRef.current?.destroy()
window.removeEventListener("resize", onResize)
}
}, [rs, config])
return (
<div
className={cn(
"absolute inset-0 mx-auto aspect-square w-full max-w-150",
className
)}
>
<canvas
className={cn(
"size-full opacity-0 transition-opacity duration-500 contain-[layout_paint_size]"
)}
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current = e.clientX
updatePointerInteraction(e.clientX)
}}
onPointerUp={() => updatePointerInteraction(null)}
onPointerOut={() => updatePointerInteraction(null)}
onMouseMove={(e) => updateMovement(e.clientX)}
onTouchMove={(e) =>
e.touches[0] && updateMovement(e.touches[0].clientX)
}
/>
</div>
)
}

View File

@@ -0,0 +1,323 @@
"use client"
import React, { useEffect, useRef, useState } from "react"
import { renderToString } from "react-dom/server"
interface Icon {
x: number
y: number
z: number
scale: number
opacity: number
id: number
}
interface IconCloudProps {
icons?: React.ReactNode[]
images?: string[]
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3)
}
export function IconCloud({ icons, images }: IconCloudProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [iconPositions, setIconPositions] = useState<Icon[]>([])
const [isDragging, setIsDragging] = useState(false)
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
const [targetRotation, setTargetRotation] = useState<{
x: number
y: number
startX: number
startY: number
distance: number
startTime: number
duration: number
} | null>(null)
const animationFrameRef = useRef<number>(0)
const rotationRef = useRef({ x: 0, y: 0 })
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([])
const imagesLoadedRef = useRef<boolean[]>([])
// Create icon canvases once when icons/images change
useEffect(() => {
if (!icons && !images) return
const items = icons ?? images ?? []
imagesLoadedRef.current = new Array(items.length).fill(false)
const newIconCanvases = items.map((item, index) => {
const offscreen = document.createElement("canvas")
offscreen.width = 40
offscreen.height = 40
const offCtx = offscreen.getContext("2d")
if (offCtx) {
if (images) {
// Handle image URLs directly
const img = new Image()
img.crossOrigin = "anonymous"
img.src = items[index] as string
img.onload = () => {
offCtx.clearRect(0, 0, offscreen.width, offscreen.height)
// Create circular clipping path
offCtx.beginPath()
offCtx.arc(20, 20, 20, 0, Math.PI * 2)
offCtx.closePath()
offCtx.clip()
// Draw the image
offCtx.drawImage(img, 0, 0, 40, 40)
imagesLoadedRef.current[index] = true
}
} else {
// Handle SVG icons
offCtx.scale(0.4, 0.4)
const svgString = renderToString(item as React.ReactElement)
const img = new Image()
img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgString)))
img.onload = () => {
offCtx.clearRect(0, 0, offscreen.width, offscreen.height)
offCtx.drawImage(img, 0, 0)
imagesLoadedRef.current[index] = true
}
}
}
return offscreen
})
iconCanvasesRef.current = newIconCanvases
}, [icons, images])
// Generate initial icon positions on a sphere
useEffect(() => {
const items = icons ?? images ?? []
const newIcons: Icon[] = []
const numIcons = items.length || 20
// Fibonacci sphere parameters
const offset = 2 / numIcons
const increment = Math.PI * (3 - Math.sqrt(5))
for (let i = 0; i < numIcons; i++) {
const y = i * offset - 1 + offset / 2
const r = Math.sqrt(1 - y * y)
const phi = i * increment
const x = Math.cos(phi) * r
const z = Math.sin(phi) * r
newIcons.push({
x: x * 100,
y: y * 100,
z: z * 100,
scale: 1,
opacity: 1,
id: i,
})
}
setIconPositions(newIcons)
}, [icons, images])
// Handle mouse events
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect || !canvasRef.current) return
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const ctx = canvasRef.current.getContext("2d")
if (!ctx) return
iconPositions.forEach((icon) => {
const cosX = Math.cos(rotationRef.current.x)
const sinX = Math.sin(rotationRef.current.x)
const cosY = Math.cos(rotationRef.current.y)
const sinY = Math.sin(rotationRef.current.y)
const rotatedX = icon.x * cosY - icon.z * sinY
const rotatedZ = icon.x * sinY + icon.z * cosY
const rotatedY = icon.y * cosX + rotatedZ * sinX
const screenX = canvasRef.current!.width / 2 + rotatedX
const screenY = canvasRef.current!.height / 2 + rotatedY
const scale = (rotatedZ + 200) / 300
const radius = 20 * scale
const dx = x - screenX
const dy = y - screenY
if (dx * dx + dy * dy < radius * radius) {
const targetX = -Math.atan2(
icon.y,
Math.sqrt(icon.x * icon.x + icon.z * icon.z)
)
const targetY = Math.atan2(icon.x, icon.z)
const currentX = rotationRef.current.x
const currentY = rotationRef.current.y
const distance = Math.sqrt(
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
)
const duration = Math.min(2000, Math.max(800, distance * 1000))
setTargetRotation({
x: targetX,
y: targetY,
startX: currentX,
startY: currentY,
distance,
startTime: performance.now(),
duration,
})
return
}
})
setIsDragging(true)
setLastMousePos({ x: e.clientX, y: e.clientY })
}
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (rect) {
const x = e.clientX - rect.left
const y = e.clientY - rect.top
setMousePos({ x, y })
}
if (isDragging) {
const deltaX = e.clientX - lastMousePos.x
const deltaY = e.clientY - lastMousePos.y
rotationRef.current = {
x: rotationRef.current.x + deltaY * 0.002,
y: rotationRef.current.y + deltaX * 0.002,
}
setLastMousePos({ x: e.clientX, y: e.clientY })
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
// Animation and rendering
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext("2d")
if (canvas && ctx) {
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const centerX = canvas.width / 2
const centerY = canvas.height / 2
const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)
const dx = mousePos.x - centerX
const dy = mousePos.y - centerY
const distance = Math.sqrt(dx * dx + dy * dy)
const speed = 0.003 + (distance / maxDistance) * 0.01
if (targetRotation) {
const elapsed = performance.now() - targetRotation.startTime
const progress = Math.min(1, elapsed / targetRotation.duration)
const easedProgress = easeOutCubic(progress)
rotationRef.current = {
x:
targetRotation.startX +
(targetRotation.x - targetRotation.startX) * easedProgress,
y:
targetRotation.startY +
(targetRotation.y - targetRotation.startY) * easedProgress,
}
if (progress >= 1) {
setTargetRotation(null)
}
} else if (!isDragging) {
rotationRef.current = {
x: rotationRef.current.x + (dy / canvas.height) * speed,
y: rotationRef.current.y + (dx / canvas.width) * speed,
}
}
iconPositions.forEach((icon, index) => {
const cosX = Math.cos(rotationRef.current.x)
const sinX = Math.sin(rotationRef.current.x)
const cosY = Math.cos(rotationRef.current.y)
const sinY = Math.sin(rotationRef.current.y)
const rotatedX = icon.x * cosY - icon.z * sinY
const rotatedZ = icon.x * sinY + icon.z * cosY
const rotatedY = icon.y * cosX + rotatedZ * sinX
const scale = (rotatedZ + 200) / 300
const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))
ctx.save()
ctx.translate(
canvas.width / 2 + rotatedX,
canvas.height / 2 + rotatedY
)
ctx.scale(scale, scale)
ctx.globalAlpha = opacity
if (icons || images) {
// Only try to render icons/images if they exist
if (
iconCanvasesRef.current[index] &&
imagesLoadedRef.current[index]
) {
ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)
}
} else {
// Show numbered circles if no icons/images are provided
ctx.beginPath()
ctx.arc(0, 0, 20, 0, Math.PI * 2)
ctx.fillStyle = "#4444ff"
ctx.fill()
ctx.fillStyle = "white"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.font = "16px Arial"
ctx.fillText(`${icon.id + 1}`, 0, 0)
}
ctx.restore()
})
animationFrameRef.current = requestAnimationFrame(animate)
}
animate()
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [icons, images, iconPositions, isDragging, mousePos, targetRotation])
return (
<canvas
ref={canvasRef}
width={400}
height={400}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className="rounded-lg"
aria-label="Interactive 3D Icon Cloud"
role="img"
/>
)
}

View File

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

61
components/ui/meteors.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client"
import React, { useEffect, useState } from "react"
import { cn } from "@/lib/utils"
interface MeteorsProps {
number?: number
minDelay?: number
maxDelay?: number
minDuration?: number
maxDuration?: number
angle?: number
className?: string
}
export const Meteors = ({
number = 20,
minDelay = 0.2,
maxDelay = 1.2,
minDuration = 2,
maxDuration = 10,
angle = 215,
className,
}: MeteorsProps) => {
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
[]
)
useEffect(() => {
const styles = [...new Array(number)].map(() => ({
"--angle": -angle + "deg",
top: "-5%",
left: `calc(0% + ${Math.floor(Math.random() * window.innerWidth)}px)`,
animationDelay: Math.random() * (maxDelay - minDelay) + minDelay + "s",
animationDuration:
Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) +
"s",
}))
setMeteorStyles(styles)
}, [number, minDelay, maxDelay, minDuration, maxDuration, angle])
return (
<>
{[...meteorStyles].map((style, idx) => (
// Meteor Head
<span
key={idx}
style={{ ...style }}
className={cn(
"animate-meteor pointer-events-none absolute size-0.5 rotate-(--angle) rounded-full bg-zinc-500 shadow-[0_0_0_1px_#ffffff10]",
className
)}
>
{/* Meteor Tail */}
<div className="pointer-events-none absolute top-1/2 -z-10 h-px w-12.5 -translate-y-1/2 bg-linear-to-r from-zinc-500 to-transparent" />
</span>
))}
</>
)
}

View File

@@ -0,0 +1,71 @@
import React from "react"
import { cn } from "@/lib/utils"
export interface OrbitingCirclesProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string
children?: React.ReactNode
reverse?: boolean
duration?: number
delay?: number
radius?: number
path?: boolean
iconSize?: number
speed?: number
}
export function OrbitingCircles({
className,
children,
reverse,
duration = 20,
radius = 160,
path = true,
iconSize = 30,
speed = 1,
...props
}: OrbitingCirclesProps) {
const calculatedDuration = duration / speed
return (
<>
{path && (
<svg
xmlns="http://www.w3.org/2000/svg"
version="1.1"
className="pointer-events-none absolute inset-0 size-full"
>
<circle
className="stroke-black/10 stroke-1 dark:stroke-white/10"
cx="50%"
cy="50%"
r={radius}
fill="none"
/>
</svg>
)}
{React.Children.map(children, (child, index) => {
const angle = (360 / React.Children.count(children)) * index
return (
<div
style={
{
"--duration": calculatedDuration,
"--radius": radius,
"--angle": angle,
"--icon-size": `${iconSize}px`,
} as React.CSSProperties
}
className={cn(
`animate-orbit absolute flex size-(--icon-size) transform-gpu items-center justify-center rounded-full`,
{ "[animation-direction:reverse]": reverse },
className
)}
{...props}
>
{child}
</div>
)
})}
</>
)
}

321
components/ui/particles.tsx Normal file
View File

@@ -0,0 +1,321 @@
"use client"
import React, {
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
} from "react"
import { cn } from "@/lib/utils"
interface MousePosition {
x: number
y: number
}
function MousePosition(): MousePosition {
const [mousePosition, setMousePosition] = useState<MousePosition>({
x: 0,
y: 0,
})
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY })
}
window.addEventListener("mousemove", handleMouseMove)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
}
}, [])
return mousePosition
}
interface ParticlesProps extends ComponentPropsWithoutRef<"div"> {
className?: string
quantity?: number
staticity?: number
ease?: number
size?: number
refresh?: boolean
color?: string
vx?: number
vy?: number
}
function hexToRgb(hex: string): number[] {
hex = hex.replace("#", "")
if (hex.length === 3) {
hex = hex
.split("")
.map((char) => char + char)
.join("")
}
const hexInt = parseInt(hex, 16)
const red = (hexInt >> 16) & 255
const green = (hexInt >> 8) & 255
const blue = hexInt & 255
return [red, green, blue]
}
type Circle = {
x: number
y: number
translateX: number
translateY: number
size: number
alpha: number
targetAlpha: number
dx: number
dy: number
magnetism: number
}
export const Particles: React.FC<ParticlesProps> = ({
className = "",
quantity = 100,
staticity = 50,
ease = 50,
size = 0.4,
refresh = false,
color = "#ffffff",
vx = 0,
vy = 0,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const context = useRef<CanvasRenderingContext2D | null>(null)
const circles = useRef<Circle[]>([])
const mousePosition = MousePosition()
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1
const rafID = useRef<number | null>(null)
const resizeTimeout = useRef<NodeJS.Timeout | null>(null)
const initCanvasRef = useRef<() => void>(() => {})
const onMouseMoveRef = useRef<() => void>(() => {})
const animateRef = useRef<() => void>(() => {})
useEffect(() => {
if (canvasRef.current) {
context.current = canvasRef.current.getContext("2d")
}
initCanvasRef.current()
animateRef.current()
const handleResize = () => {
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current)
}
resizeTimeout.current = setTimeout(() => {
initCanvasRef.current()
}, 200)
}
window.addEventListener("resize", handleResize)
return () => {
if (rafID.current != null) {
window.cancelAnimationFrame(rafID.current)
}
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current)
}
window.removeEventListener("resize", handleResize)
}
}, [color])
useEffect(() => {
onMouseMoveRef.current()
}, [mousePosition.x, mousePosition.y])
useEffect(() => {
initCanvasRef.current()
}, [refresh])
const initCanvas = () => {
resizeCanvas()
drawParticles()
}
const onMouseMove = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect()
const { w, h } = canvasSize.current
const x = mousePosition.x - rect.left - w / 2
const y = mousePosition.y - rect.top - h / 2
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
if (inside) {
mouse.current.x = x
mouse.current.y = y
}
}
}
const resizeCanvas = () => {
if (canvasContainerRef.current && canvasRef.current && context.current) {
canvasSize.current.w = canvasContainerRef.current.offsetWidth
canvasSize.current.h = canvasContainerRef.current.offsetHeight
canvasRef.current.width = canvasSize.current.w * dpr
canvasRef.current.height = canvasSize.current.h * dpr
canvasRef.current.style.width = `${canvasSize.current.w}px`
canvasRef.current.style.height = `${canvasSize.current.h}px`
context.current.scale(dpr, dpr)
// Clear existing particles and create new ones with exact quantity
circles.current = []
for (let i = 0; i < quantity; i++) {
const circle = circleParams()
drawCircle(circle)
}
}
}
const circleParams = (): Circle => {
const x = Math.floor(Math.random() * canvasSize.current.w)
const y = Math.floor(Math.random() * canvasSize.current.h)
const translateX = 0
const translateY = 0
const pSize = Math.floor(Math.random() * 2) + size
const alpha = 0
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
const dx = (Math.random() - 0.5) * 0.1
const dy = (Math.random() - 0.5) * 0.1
const magnetism = 0.1 + Math.random() * 4
return {
x,
y,
translateX,
translateY,
size: pSize,
alpha,
targetAlpha,
dx,
dy,
magnetism,
}
}
const rgb = hexToRgb(color)
const drawCircle = (circle: Circle, update = false) => {
if (context.current) {
const { x, y, translateX, translateY, size, alpha } = circle
context.current.translate(translateX, translateY)
context.current.beginPath()
context.current.arc(x, y, size, 0, 2 * Math.PI)
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`
context.current.fill()
context.current.setTransform(dpr, 0, 0, dpr, 0, 0)
if (!update) {
circles.current.push(circle)
}
}
}
const clearContext = () => {
if (context.current) {
context.current.clearRect(
0,
0,
canvasSize.current.w,
canvasSize.current.h
)
}
}
const drawParticles = () => {
clearContext()
const particleCount = quantity
for (let i = 0; i < particleCount; i++) {
const circle = circleParams()
drawCircle(circle)
}
}
const remapValue = (
value: number,
start1: number,
end1: number,
start2: number,
end2: number
): number => {
const remapped =
((value - start1) * (end2 - start2)) / (end1 - start1) + start2
return remapped > 0 ? remapped : 0
}
const animate = () => {
clearContext()
circles.current.forEach((circle: Circle, i: number) => {
// Handle the alpha value
const edge = [
circle.x + circle.translateX - circle.size, // distance from left edge
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
circle.y + circle.translateY - circle.size, // distance from top edge
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
]
const closestEdge = edge.reduce((a, b) => Math.min(a, b))
const remapClosestEdge = parseFloat(
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
)
if (remapClosestEdge > 1) {
circle.alpha += 0.02
if (circle.alpha > circle.targetAlpha) {
circle.alpha = circle.targetAlpha
}
} else {
circle.alpha = circle.targetAlpha * remapClosestEdge
}
circle.x += circle.dx + vx
circle.y += circle.dy + vy
circle.translateX +=
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
ease
circle.translateY +=
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
ease
drawCircle(circle, true)
// circle gets out of the canvas
if (
circle.x < -circle.size ||
circle.x > canvasSize.current.w + circle.size ||
circle.y < -circle.size ||
circle.y > canvasSize.current.h + circle.size
) {
// remove the circle from the array
circles.current.splice(i, 1)
// create a new circle
const newCircle = circleParams()
drawCircle(newCircle)
}
})
rafID.current = window.requestAnimationFrame(animateRef.current)
}
initCanvasRef.current = initCanvas
onMouseMoveRef.current = onMouseMove
animateRef.current = animate
return (
<div
className={cn("pointer-events-none", className)}
ref={canvasContainerRef}
aria-hidden="true"
{...props}
>
<canvas ref={canvasRef} className="size-full" />
</div>
)
}

View File

@@ -0,0 +1,34 @@
"use client"
import { motion, useScroll, type MotionProps } from "motion/react"
import { cn } from "@/lib/utils"
interface ScrollProgressProps extends Omit<
React.HTMLAttributes<HTMLElement>,
keyof MotionProps
> {
ref?: React.Ref<HTMLDivElement>
}
export function ScrollProgress({
className,
ref,
...props
}: ScrollProgressProps) {
const { scrollYProgress } = useScroll()
return (
<motion.div
ref={ref}
className={cn(
"fixed inset-x-0 top-0 z-50 h-px origin-left bg-linear-to-r from-[#A97CF8] via-[#F38CB8] to-[#FDCC92]",
className
)}
style={{
scaleX: scrollYProgress,
}}
{...props}
/>
)
}

View File

@@ -0,0 +1,74 @@
"use client"
import React from "react"
import { motion, type MotionProps } from "motion/react"
import { cn } from "@/lib/utils"
const animationProps: MotionProps = {
initial: { "--x": "100%", scale: 0.8 },
animate: { "--x": "-100%", scale: 1 },
whileTap: { scale: 0.95 },
transition: {
repeat: Infinity,
repeatType: "loop",
repeatDelay: 1,
type: "spring",
stiffness: 20,
damping: 15,
mass: 2,
scale: {
type: "spring",
stiffness: 200,
damping: 5,
mass: 0.5,
},
},
}
interface ShinyButtonProps
extends
Omit<React.HTMLAttributes<HTMLElement>, keyof MotionProps>,
MotionProps {
children: React.ReactNode
className?: string
}
export const ShinyButton = React.forwardRef<
HTMLButtonElement,
ShinyButtonProps
>(({ children, className, ...props }, ref) => {
return (
<motion.button
ref={ref}
className={cn(
"relative cursor-pointer rounded-lg border px-6 py-2 font-medium backdrop-blur-xl transition-shadow duration-300 ease-in-out hover:shadow dark:bg-[radial-gradient(circle_at_50%_0%,var(--primary)/10%_0%,transparent_60%)] dark:hover:shadow-[0_0_20px_var(--primary)/10%]",
className
)}
{...animationProps}
{...props}
>
<span
className="relative block size-full text-sm tracking-wide text-[rgb(0,0,0,65%)] uppercase dark:font-light dark:text-[rgb(255,255,255,90%)]"
style={{
maskImage:
"linear-gradient(-75deg,var(--primary) calc(var(--x) + 20%),transparent calc(var(--x) + 30%),var(--primary) calc(var(--x) + 100%))",
}}
>
{children}
</span>
<span
style={{
mask: "linear-gradient(rgb(0,0,0), rgb(0,0,0)) content-box exclude,linear-gradient(rgb(0,0,0), rgb(0,0,0))",
WebkitMask:
"linear-gradient(rgb(0,0,0), rgb(0,0,0)) content-box exclude,linear-gradient(rgb(0,0,0), rgb(0,0,0))",
backgroundImage:
"linear-gradient(-75deg,var(--primary)/10% calc(var(--x)+20%),var(--primary)/50% calc(var(--x)+25%),var(--primary)/10% calc(var(--x)+100%))",
}}
className="absolute inset-0 z-10 block rounded-[inherit] p-px"
/>
</motion.button>
)
})
ShinyButton.displayName = "ShinyButton"

View File

@@ -0,0 +1,445 @@
"use client"
import { memo } from "react"
import {
AnimatePresence,
motion,
Variants,
type DOMMotionComponents,
type MotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
type AnimationType = "text" | "word" | "character" | "line"
type AnimationVariant =
| "fadeIn"
| "blurIn"
| "blurInUp"
| "blurInDown"
| "slideUp"
| "slideDown"
| "slideLeft"
| "slideRight"
| "scaleUp"
| "scaleDown"
const motionElements = {
article: motion.article,
div: motion.div,
h1: motion.h1,
h2: motion.h2,
h3: motion.h3,
h4: motion.h4,
h5: motion.h5,
h6: motion.h6,
li: motion.li,
p: motion.p,
section: motion.section,
span: motion.span,
} as const
type MotionElementType = Extract<
keyof DOMMotionComponents,
keyof typeof motionElements
>
interface TextAnimateProps extends Omit<MotionProps, "children"> {
/**
* The text content to animate
*/
children: string
/**
* The class name to be applied to the component
*/
className?: string
/**
* The class name to be applied to each segment
*/
segmentClassName?: string
/**
* The delay before the animation starts
*/
delay?: number
/**
* The duration of the animation
*/
duration?: number
/**
* Custom motion variants for the animation
*/
variants?: Variants
/**
* The element type to render
*/
as?: MotionElementType
/**
* How to split the text ("text", "word", "character")
*/
by?: AnimationType
/**
* Whether to start animation when component enters viewport
*/
startOnView?: boolean
/**
* Whether to animate only once
*/
once?: boolean
/**
* The animation preset to use
*/
animation?: AnimationVariant
/**
* Whether to enable accessibility features (default: true)
*/
accessible?: boolean
}
const staggerTimings: Record<AnimationType, number> = {
text: 0.06,
word: 0.05,
character: 0.03,
line: 0.06,
}
const defaultContainerVariants = {
hidden: { opacity: 1 },
show: {
opacity: 1,
transition: {
delayChildren: 0,
staggerChildren: 0.05,
},
},
exit: {
opacity: 0,
transition: {
staggerChildren: 0.05,
staggerDirection: -1,
},
},
}
const defaultItemVariants: Variants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
},
exit: {
opacity: 0,
},
}
const defaultItemAnimationVariants: Record<
AnimationVariant,
{ container: Variants; item: Variants }
> = {
fadeIn: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
},
},
exit: {
opacity: 0,
y: 20,
transition: { duration: 0.3 },
},
},
},
blurIn: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(10px)" },
show: {
opacity: 1,
filter: "blur(0px)",
transition: {
duration: 0.3,
},
},
exit: {
opacity: 0,
filter: "blur(10px)",
transition: { duration: 0.3 },
},
},
},
blurInUp: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(10px)", y: 20 },
show: {
opacity: 1,
filter: "blur(0px)",
y: 0,
transition: {
y: { duration: 0.3 },
opacity: { duration: 0.4 },
filter: { duration: 0.3 },
},
},
exit: {
opacity: 0,
filter: "blur(10px)",
y: 20,
transition: {
y: { duration: 0.3 },
opacity: { duration: 0.4 },
filter: { duration: 0.3 },
},
},
},
},
blurInDown: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(10px)", y: -20 },
show: {
opacity: 1,
filter: "blur(0px)",
y: 0,
transition: {
y: { duration: 0.3 },
opacity: { duration: 0.4 },
filter: { duration: 0.3 },
},
},
},
},
slideUp: {
container: defaultContainerVariants,
item: {
hidden: { y: 20, opacity: 0 },
show: {
y: 0,
opacity: 1,
transition: {
duration: 0.3,
},
},
exit: {
y: -20,
opacity: 0,
transition: {
duration: 0.3,
},
},
},
},
slideDown: {
container: defaultContainerVariants,
item: {
hidden: { y: -20, opacity: 0 },
show: {
y: 0,
opacity: 1,
transition: { duration: 0.3 },
},
exit: {
y: 20,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
slideLeft: {
container: defaultContainerVariants,
item: {
hidden: { x: 20, opacity: 0 },
show: {
x: 0,
opacity: 1,
transition: { duration: 0.3 },
},
exit: {
x: -20,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
slideRight: {
container: defaultContainerVariants,
item: {
hidden: { x: -20, opacity: 0 },
show: {
x: 0,
opacity: 1,
transition: { duration: 0.3 },
},
exit: {
x: 20,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
scaleUp: {
container: defaultContainerVariants,
item: {
hidden: { scale: 0.5, opacity: 0 },
show: {
scale: 1,
opacity: 1,
transition: {
duration: 0.3,
scale: {
type: "spring",
damping: 15,
stiffness: 300,
},
},
},
exit: {
scale: 0.5,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
scaleDown: {
container: defaultContainerVariants,
item: {
hidden: { scale: 1.5, opacity: 0 },
show: {
scale: 1,
opacity: 1,
transition: {
duration: 0.3,
scale: {
type: "spring",
damping: 15,
stiffness: 300,
},
},
},
exit: {
scale: 1.5,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
}
const TextAnimateBase = ({
children,
delay = 0,
duration = 0.3,
variants,
className,
segmentClassName,
as: Component = "p",
startOnView = true,
once = false,
by = "word",
animation = "fadeIn",
accessible = true,
...props
}: TextAnimateProps) => {
const MotionComponent = motionElements[Component]
let segments: string[] = []
switch (by) {
case "word":
segments = children.split(/(\s+)/)
break
case "character":
segments = children.split("")
break
case "line":
segments = children.split("\n")
break
case "text":
default:
segments = [children]
break
}
const finalVariants = variants
? {
container: {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
opacity: { duration: 0.01, delay },
delayChildren: delay,
staggerChildren: duration / segments.length,
},
},
exit: {
opacity: 0,
transition: {
staggerChildren: duration / segments.length,
staggerDirection: -1,
},
},
},
item: variants,
}
: animation
? {
container: {
...defaultItemAnimationVariants[animation].container,
show: {
...defaultItemAnimationVariants[animation].container.show,
transition: {
delayChildren: delay,
staggerChildren: duration / segments.length,
},
},
exit: {
...defaultItemAnimationVariants[animation].container.exit,
transition: {
staggerChildren: duration / segments.length,
staggerDirection: -1,
},
},
},
item: defaultItemAnimationVariants[animation].item,
}
: { container: defaultContainerVariants, item: defaultItemVariants }
return (
<AnimatePresence mode="popLayout">
<MotionComponent
variants={finalVariants.container as Variants}
initial="hidden"
whileInView={startOnView ? "show" : undefined}
animate={startOnView ? undefined : "show"}
exit="exit"
className={cn("whitespace-pre-wrap", className)}
viewport={{ once }}
aria-label={accessible ? children : undefined}
{...props}
>
{accessible && <span className="sr-only">{children}</span>}
{segments.map((segment, i) => (
<motion.span
key={`${by}-${segment}-${i}`}
variants={finalVariants.item}
custom={i * staggerTimings[by]}
className={cn(
by === "line" ? "block" : "inline-block whitespace-pre",
by === "character" && "",
segmentClassName
)}
aria-hidden={accessible ? true : undefined}
>
{segment}
</motion.span>
))}
</MotionComponent>
</AnimatePresence>
)
}
// Export the memoized version
export const TextAnimate = memo(TextAnimateBase)