feat: implement the first version of landing page

This commit is contained in:
Henry Li
2026-01-23 13:24:03 +08:00
parent 307972f93e
commit 3f4bcd9433
25 changed files with 2576 additions and 241 deletions

View File

@@ -57,6 +57,7 @@
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
@@ -81,6 +82,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -131,6 +131,9 @@ importers:
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.3)
gsap:
specifier: ^3.13.0
version: 3.14.2
hast:
specifier: ^1.0.0
version: 1.0.0
@@ -198,6 +201,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.0.15
version: 4.1.18
'@types/gsap':
specifier: ^3.0.0
version: 3.0.0
'@types/node':
specifier: ^20.14.10
version: 20.19.29
@@ -2008,6 +2014,10 @@ packages:
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/gsap@3.0.0':
resolution: {integrity: sha512-BbWLi4WRHGze4C8NV7U7yRevuBFiPkPZZyGa0rryanvh/9HPUFXTNBXsGQxJZJq7Ix7j4RXMYodP3s+OsqCErg==}
deprecated: This is a stub types definition. gsap provides its own type definitions, so you do not need this installed.
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -3294,6 +3304,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.14.2:
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
h3@1.15.5:
resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==}
@@ -6890,6 +6903,10 @@ snapshots:
'@types/geojson@7946.0.16': {}
'@types/gsap@3.0.0':
dependencies:
gsap: 3.14.2
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -8407,6 +8424,8 @@ snapshots:
graceful-fs@4.2.11: {}
gsap@3.14.2: {}
h3@1.15.5:
dependencies:
cookie-es: 1.2.2

View File

@@ -1,82 +1,25 @@
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Jumbotron } from "@/components/landing/jumbotron";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import { env } from "@/env";
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { Hero } from "@/components/landing/hero";
import { CaseStudySection } from "@/components/landing/sections/case-study-section";
import { CommunitySection } from "@/components/landing/sections/community-section";
import { SandboxSection } from "@/components/landing/sections/sandbox-section";
import { SkillsSection } from "@/components/landing/sections/skills-section";
import { WhatsNewSection } from "@/components/landing/sections/whats-new-section";
export default function LandingPage() {
return (
<div className="min-h-screen w-full bg-[#0a0a0a]">
<header className="container-md absolute top-0 right-0 left-0 z-10 mx-auto flex h-16 items-center justify-between backdrop-blur-xs">
<div className="font-serif text-xl">
<h1>DeerFlow</h1>
</div>
<div className="relative">
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
style={{
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
filter: "blur(16px)",
}}
/>
<Button
variant="outline"
size="sm"
asChild
className="group relative z-10"
>
<Link href="https://github.com/bytedance/deer-flow" target="_blank">
<GitHubLogoIcon className="size-4" />
Star on GitHub
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</Link>
</Button>
</div>
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
</header>
<main className="w-full">
<Jumbotron />
<Header />
<main className="flex w-full flex-col">
<Hero />
<CaseStudySection />
<SkillsSection />
<SandboxSection />
<WhatsNewSection />
<CommunitySection />
</main>
<footer className="container-md mx-auto"></footer>
<Footer />
</div>
);
}
export async function StarCounter() {
let stars = 10000; // Default value
try {
const response = await fetch(
"https://api.github.com/repos/bytedance/deer-flow",
{
headers: env.GITHUB_OAUTH_TOKEN
? {
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
"Content-Type": "application/json",
}
: {},
next: {
revalidate: 3600,
},
},
);
if (response.ok) {
const data = await response.json();
stars = data.stargazers_count ?? stars; // Update stars if API response is valid
}
} catch (error) {
console.error("Error fetching GitHub stars:", error);
}
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
{stars && (
<NumberTicker className="font-mono tabular-nums" value={stars} />
)}
</>
);
}

View File

@@ -0,0 +1,652 @@
"use client";
import {
Folder,
FileText,
Search,
Globe,
Check,
Sparkles,
Terminal,
Play,
} from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useState, useEffect, useRef } from "react";
type AnimationPhase =
| "idle"
| "user-input"
| "scanning"
| "load-skill"
| "load-template"
| "researching"
| "load-frontend"
| "building"
| "load-deploy"
| "deploying"
| "done";
interface FileItem {
name: string;
type: "folder" | "file";
indent: number;
highlight?: boolean;
active?: boolean;
done?: boolean;
dragging?: boolean;
}
const searchSteps = [
{ type: "search", text: "mRNA lipid nanoparticle delivery 2024" },
{ type: "fetch", text: "nature.com/articles/s41587-024..." },
{ type: "search", text: "LNP ionizable lipids efficiency" },
{ type: "fetch", text: "pubs.acs.org/doi/10.1021/..." },
{ type: "search", text: "targeted mRNA tissue-specific" },
];
// Animation duration configuration - adjust the duration for each step here
const ANIMATION_DELAYS = {
"user-input": 0, // User input phase duration (milliseconds)
scanning: 2000, // Scanning phase duration
"load-skill": 1500, // Load skill phase duration
"load-template": 1200, // Load template phase duration
researching: 800, // Researching phase duration
"load-frontend": 800, // Load frontend phase duration
building: 1200, // Building phase duration
"load-deploy": 2500, // Load deploy phase duration
deploying: 1200, // Deploying phase duration
done: 2500, // Done phase duration (final step)
} as const;
export default function ProgressiveSkillsAnimation() {
const [phase, setPhase] = useState<AnimationPhase>("idle");
const [searchIndex, setSearchIndex] = useState(0);
const [buildIndex, setBuildIndex] = useState(0);
const [, setChatMessages] = useState<React.ReactNode[]>([]);
const [, setShowWorkspace] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [hasPlayed, setHasPlayed] = useState(false);
const [hasAutoPlayed, setHasAutoPlayed] = useState(false);
const chatMessagesRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Additional display duration after the final step (done) completes, used to show the final result
const FINAL_DISPLAY_DURATION = 3000; // milliseconds
// Play animation only when isPlaying is true
useEffect(() => {
if (!isPlaying) return;
const timeline = [
{ phase: "user-input" as const, delay: ANIMATION_DELAYS["user-input"] },
{ phase: "scanning" as const, delay: ANIMATION_DELAYS.scanning },
{ phase: "load-skill" as const, delay: ANIMATION_DELAYS["load-skill"] },
{
phase: "load-template" as const,
delay: ANIMATION_DELAYS["load-template"],
},
{ phase: "researching" as const, delay: ANIMATION_DELAYS.researching },
{
phase: "load-frontend" as const,
delay: ANIMATION_DELAYS["load-frontend"],
},
{ phase: "building" as const, delay: ANIMATION_DELAYS.building },
{ phase: "load-deploy" as const, delay: ANIMATION_DELAYS["load-deploy"] },
{ phase: "deploying" as const, delay: ANIMATION_DELAYS.deploying },
{ phase: "done" as const, delay: ANIMATION_DELAYS.done },
];
let totalDelay = 0;
const timeouts: NodeJS.Timeout[] = [];
timeline.forEach(({ phase, delay }) => {
totalDelay += delay;
timeouts.push(setTimeout(() => setPhase(phase), totalDelay));
});
// Reset after animation completes
// Total duration for the final step = ANIMATION_DELAYS["done"] + FINAL_DISPLAY_DURATION
timeouts.push(
setTimeout(() => {
setPhase("idle");
setChatMessages([]);
setSearchIndex(0);
setBuildIndex(0);
setShowWorkspace(false);
setIsPlaying(false);
}, totalDelay + FINAL_DISPLAY_DURATION),
);
return () => timeouts.forEach(clearTimeout);
}, [isPlaying]);
const handlePlay = () => {
setIsPlaying(true);
setHasPlayed(true);
setPhase("idle");
setChatMessages([]);
setSearchIndex(0);
setBuildIndex(0);
setShowWorkspace(false);
};
// Auto-play when component enters viewport for the first time
useEffect(() => {
if (hasAutoPlayed || !containerRef.current) return;
const containerElement = containerRef.current;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasAutoPlayed && !isPlaying) {
setHasAutoPlayed(true);
// Small delay before auto-playing for better UX
setTimeout(() => {
setIsPlaying(true);
setHasPlayed(true);
setPhase("idle");
setChatMessages([]);
setSearchIndex(0);
setBuildIndex(0);
setShowWorkspace(false);
}, 300);
}
});
},
{
threshold: 0.3, // Trigger when 30% of the component is visible
rootMargin: "0px",
},
);
observer.observe(containerElement);
return () => {
if (containerElement) {
observer.unobserve(containerElement);
}
};
}, [hasAutoPlayed, isPlaying]);
// Handle search animation
useEffect(() => {
if (phase === "researching" && searchIndex < searchSteps.length) {
const timer = setTimeout(() => {
setSearchIndex((i) => i + 1);
}, 350);
return () => clearTimeout(timer);
}
}, [phase, searchIndex]);
// Handle build animation
useEffect(() => {
if (phase === "building" && buildIndex < 3) {
const timer = setTimeout(() => {
setBuildIndex((i) => i + 1);
}, 600);
return () => clearTimeout(timer);
}
if (phase === "building") {
setShowWorkspace(true);
}
}, [phase, buildIndex]);
// Auto scroll chat to bottom when messages change
useEffect(() => {
if (chatMessagesRef.current && phase !== "idle") {
chatMessagesRef.current.scrollTo({
top: chatMessagesRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [phase, searchIndex, buildIndex]);
const getFileTree = (): FileItem[] => {
const base: FileItem[] = [
{
name: "deep-search",
type: "folder",
indent: 0,
highlight: phase === "scanning",
active: ["load-skill", "load-template", "researching"].includes(phase),
done: [
"researching",
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase),
},
{
name: "SKILL.md",
type: "file",
indent: 1,
highlight: phase === "scanning",
dragging: phase === "load-skill",
done: [
"load-template",
"researching",
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase),
},
{
name: "biotech.md",
type: "file",
indent: 1,
highlight: phase === "load-template",
dragging: phase === "load-template",
done: [
"researching",
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase),
},
{ name: "computer-science.md", type: "file", indent: 1 },
{ name: "physics.md", type: "file", indent: 1 },
{
name: "frontend-design",
type: "folder",
indent: 0,
highlight: phase === "scanning",
active: ["load-frontend", "building"].includes(phase),
done: ["building", "load-deploy", "deploying", "done"].includes(phase),
},
{
name: "SKILL.md",
type: "file",
indent: 1,
highlight: phase === "scanning",
dragging: phase === "load-frontend",
done: ["building", "load-deploy", "deploying", "done"].includes(phase),
},
{
name: "deploy",
type: "folder",
indent: 0,
highlight: phase === "scanning",
active: ["load-deploy", "deploying"].includes(phase),
done: ["deploying", "done"].includes(phase),
},
{
name: "SKILL.md",
type: "file",
indent: 1,
highlight: phase === "scanning",
dragging: phase === "load-deploy",
done: ["deploying", "done"].includes(phase),
},
{
name: "scripts",
type: "folder",
indent: 1,
done: ["deploying", "done"].includes(phase),
},
{
name: "deploy.sh",
type: "file",
indent: 2,
done: ["deploying", "done"].includes(phase),
},
];
return base;
};
const workspaceFiles = ["index.html", "index.css", "index.js"];
return (
<div
ref={containerRef}
className="relative flex h-[calc(100vh-280px)] w-full items-center justify-center overflow-hidden p-8"
>
{/* Overlay and Play Button */}
<AnimatePresence>
{!isPlaying && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 flex items-center justify-center"
>
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
onClick={handlePlay}
className="group flex flex-col items-center gap-4 transition-transform hover:scale-105 active:scale-95"
>
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/10 backdrop-blur-md transition-all group-hover:bg-white/20">
<Play
size={48}
className="ml-1 text-white transition-transform group-hover:scale-110"
fill="white"
/>
</div>
<span className="text-lg font-medium text-white">
{hasPlayed ? "Click to replay" : "Click to play"}
</span>
</motion.button>
</motion.div>
)}
</AnimatePresence>
<div className="flex h-full max-h-[700px] w-full max-w-6xl gap-8">
{/* Left: File Tree */}
<div className="flex flex-1 flex-col">
<motion.div
className="mb-4 font-mono text-sm text-zinc-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
/mnt/skills/
</motion.div>
<div className="space-y-2">
{getFileTree().map((item, index) => (
<motion.div
key={`${item.name}-${index}`}
className={`flex items-center gap-3 text-lg font-medium transition-all duration-300 ${
item.done
? "text-green-500"
: item.dragging
? "translate-x-8 scale-105 text-blue-400"
: item.active
? "text-white"
: item.highlight
? "text-purple-400"
: "text-zinc-600"
}`}
style={{ paddingLeft: `${item.indent * 24}px` }}
animate={
item.done
? {
scale: 1,
opacity: 1,
}
: {}
}
>
{item.type === "folder" ? (
<Folder
size={20}
className={
item.done
? "text-green-500"
: item.highlight
? "text-purple-400"
: ""
}
/>
) : (
<FileText
size={20}
className={
item.done
? "text-green-500"
: item.highlight
? "text-purple-400"
: ""
}
/>
)}
<span>{item.name}</span>
{item.done && <Check size={16} className="text-green-500" />}
{item.highlight && !item.done && (
<Sparkles size={16} className="text-purple-400" />
)}
</motion.div>
))}
</div>
</div>
{/* Right: Chat Interface */}
<div className="flex flex-1 flex-col overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-900/50">
{/* Chat Header */}
<div className="border-b border-zinc-800 p-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-sm text-zinc-400">DeerFlow Agent</span>
</div>
</div>
{/* Chat Messages */}
<div
ref={chatMessagesRef}
className="flex-1 space-y-4 overflow-y-auto p-6"
>
{/* User Message */}
<AnimatePresence>
{phase !== "idle" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-end"
>
<div className="max-w-[90%] rounded-2xl rounded-tr-sm bg-blue-600 px-5 py-3">
<p className="text-base">
Research mRNA delivery, build a landing page, deploy to
Vercel
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Agent Messages */}
<AnimatePresence>
{phase !== "idle" && phase !== "user-input" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-3"
>
{/* Found Skills */}
{[
"scanning",
"load-skill",
"load-template",
"researching",
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase) && (
<div className="text-base text-zinc-300">
<span className="text-purple-400"></span> Found 3 skills
</div>
)}
{/* Researching Section */}
{[
"load-skill",
"load-template",
"researching",
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase) && (
<div className="mt-4">
<hr className="mb-3 border-zinc-700" />
<div className="mb-3 text-zinc-300">
🔬 Researching...
</div>
<div className="mb-3 space-y-2">
{/* Loading SKILL.md */}
{[
"load-skill",
"load-template",
"researching",
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase) && (
<div className="flex items-center gap-2 pl-4 text-zinc-400">
<FileText size={16} />
<span>Loading deep-search/SKILL.md...</span>
</div>
)}
{/* Loading biotech.md */}
{[
"load-template",
"researching",
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase) && (
<div className="flex items-center gap-2 pl-4 text-zinc-400">
<FileText size={16} />
<span>
Found biotech related topic, loading
deep-search/biotech.md...
</span>
</div>
)}
</div>
{/* Search steps */}
{phase === "researching" && (
<div className="max-h-[180px] space-y-2 overflow-hidden pl-4">
{searchSteps.slice(0, searchIndex).map((step, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 text-sm text-zinc-500"
>
{step.type === "search" ? (
<Search size={14} className="text-blue-400" />
) : (
<Globe size={14} className="text-green-400" />
)}
<span className="truncate">{step.text}</span>
</motion.div>
))}
</div>
)}
{[
"load-frontend",
"building",
"load-deploy",
"deploying",
"done",
].includes(phase) && (
<div className="max-h-[180px] space-y-2 overflow-hidden pl-4">
{searchSteps.map((step, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 text-sm text-zinc-500"
>
{step.type === "search" ? (
<Search size={14} className="text-blue-400" />
) : (
<Globe size={14} className="text-green-400" />
)}
<span className="truncate">{step.text}</span>
</motion.div>
))}
</div>
)}
</div>
)}
{/* Building */}
{["building", "load-deploy", "deploying", "done"].includes(
phase,
) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-4"
>
<hr className="mb-3 border-zinc-700" />
<div className="mb-3 text-zinc-300">🔨 Building...</div>
<div className="mb-3 flex items-center gap-2 pl-4 text-zinc-400">
<FileText size={16} />
<span>Loading frontend-design/SKILL.md...</span>
</div>
<div className="space-y-2 pl-4">
{workspaceFiles.slice(0, buildIndex).map((file) => (
<motion.div
key={file}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2 text-sm text-green-500"
>
<FileText size={14} />
<span>{file}</span>
<Check size={14} />
</motion.div>
))}
</div>
</motion.div>
)}
{/* Deploying */}
{["load-deploy", "deploying", "done"].includes(phase) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-4"
>
<hr className="mb-3 border-zinc-700" />
<div className="mb-3 text-zinc-300">🚀 Deploying...</div>
<div className="mb-3 space-y-2">
<div className="flex items-center gap-2 pl-4 text-zinc-400">
<FileText size={16} />
<span>Loading deploy/SKILL.md...</span>
</div>
{["deploying", "done"].includes(phase) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2 pl-4 text-zinc-400"
>
<Terminal size={16} />
<span>Executing deploy.sh</span>
</motion.div>
)}
</div>
{phase === "done" && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="mt-4 rounded-xl border border-green-500/30 bg-green-500/10 p-4"
>
<div className="text-lg font-medium text-green-500">
Live at biotech-startup.vercel.app
</div>
</motion.div>
)}
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Chat Input (decorative) */}
<div className="border-t border-zinc-800 p-4">
<div className="rounded-xl bg-zinc-800 px-4 py-3 text-sm text-zinc-500">
Ask DeerFlow anything...
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { useMemo } from "react";
export function Footer() {
const year = useMemo(() => new Date().getFullYear(), []);
return (
<footer className="container-md mx-auto mt-32 flex flex-col items-center justify-center">
<hr className="from-border/0 to-border/0 m-0 h-px w-full border-none bg-linear-to-r via-white/20" />
<div className="text-muted-foreground container flex h-20 flex-col items-center justify-center text-sm">
<p className="text-center font-serif text-lg md:text-xl">
&quot;Originated from Open Source, give back to Open Source.&quot;
</p>
</div>
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
<p>Licensed under MIT License</p>
<p>&copy; {year} DeerFlow</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,76 @@
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import { env } from "@/env";
export function Header() {
return (
<header className="container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs">
<div className="flex items-center gap-2">
<a href="https://github.com/bytedance/deer-flow" target="_blank">
<h1 className="font-serif text-xl">DeerFlow</h1>
</a>
</div>
<div className="relative">
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
style={{
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
filter: "blur(16px)",
}}
/>
<Button
variant="outline"
size="sm"
asChild
className="group relative z-10"
>
<a href="https://github.com/bytedance/deer-flow" target="_blank">
<GitHubLogoIcon className="size-4" />
Star on GitHub
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</a>
</Button>
</div>
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
</header>
);
}
async function StarCounter() {
let stars = 10000; // Default value
try {
const response = await fetch(
"https://api.github.com/repos/bytedance/deer-flow",
{
headers: env.GITHUB_OAUTH_TOKEN
? {
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
"Content-Type": "application/json",
}
: {},
next: {
revalidate: 3600,
},
},
);
if (response.ok) {
const data = await response.json();
stars = data.stargazers_count ?? stars; // Update stars if API response is valid
}
} catch (error) {
console.error("Error fetching GitHub stars:", error);
}
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
{stars && (
<NumberTicker className="font-mono tabular-nums" value={stars} />
)}
</>
);
}

View File

@@ -1,14 +1,15 @@
"use client";
import { ChevronRightIcon } from "lucide-react";
import Link from "next/link";
import Galaxy from "@/components/Galaxy";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy";
import { WordRotate } from "@/components/ui/word-rotate";
import { cn } from "@/lib/utils";
export function Jumbotron({ className }: { className?: string }) {
export function Hero({ className }: { className?: string }) {
return (
<div
className={cn(
@@ -16,30 +17,28 @@ export function Jumbotron({ className }: { className?: string }) {
className,
)}
>
<div className="absolute inset-0 z-0 bg-black">
<div className="absolute inset-0 z-0 bg-black/40">
<Galaxy
mouseRepulsion={false}
starSpeed={0.2}
density={0.6}
glowIntensity={0.3}
glowIntensity={0.35}
twinkleIntensity={0.3}
speed={0.5}
/>
</div>
<FlickeringGrid
className="absolute inset-0 z-0 translate-y-[2vh] mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
className="absolute inset-0 z-0 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
squareSize={4}
gridGap={4}
color={"white"}
maxOpacity={0.2}
maxOpacity={0.3}
flickerChance={0.25}
/>
<div className="container-md relative z-10 mx-auto flex h-screen flex-col items-center justify-center">
<h1 className="flex items-center gap-2 text-4xl font-bold md:text-6xl">
<WordRotate
words={[
"Do Anything",
"Learn Anything",
"Deep Research",
"Collect Data",
"Analyze Data",
@@ -51,21 +50,28 @@ export function Jumbotron({ className }: { className?: string }) {
"Generate Videos",
"Generate Songs",
"Organize Emails",
"Do Anything",
"Learn Anything",
]}
/>{" "}
<div>with DeerFlow</div>
</h1>
<p className="mt-8 scale-105 text-center text-2xl opacity-70">
<p
className="mt-8 scale-105 text-center text-2xl text-shadow-sm"
style={{ color: "rgb(180,180,185)" }}
>
DeerFlow is an open-source SuperAgent that researches, codes, and
<br />
creates. With the help of sandboxes, tools and skills, it handles
<br />
different levels of tasks that could take minutes to hours.
</p>
<Link href="/workspace">
<Button className="size-lg mt-8 scale-108" size="lg">
<span className="text-md">Get Started with 2.0</span>
<ChevronRightIcon className="size-4" />
</Button>
</Link>
</div>
</div>
);

View File

@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
export function Section({
className,
title,
subtitle,
children,
}: {
className?: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className={cn("mx-auto flex flex-col py-16", className)}>
<header className="flex flex-col items-center justify-between">
<div className="mb-4 bg-linear-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-5xl font-bold text-transparent">
{title}
</div>
{subtitle && (
<div className="text-muted-foreground text-center text-xl">
{subtitle}
</div>
)}
</header>
<main className="mt-4">{children}</main>
</section>
);
}

View File

@@ -0,0 +1,67 @@
import { SparklesIcon } from "lucide-react";
import SpotlightCard from "@/components/ui/spotlight-card";
import { Section } from "../section";
export function CaseStudySection({ className }: { className?: string }) {
const caseStudies = [
{
title: "2025 Survey",
description:
"A 12,000-word research report analyzing 47 papers on brain-inspired chips, covering Intel Loihi 2, IBM NorthPole, and SynSense's edge AI solutions.",
},
{
title: "Indie Hacker's SaaS Landing Page",
description:
"A fully responsive landing page with hero section, pricing table, testimonials, and Stripe integration — shipped in one conversation.",
},
{
title: "Transformer Architecture Explained",
description:
"A 25-slide presentation breaking down self-attention, positional encoding, and KV-cache with hand-drawn style diagrams for a university lecture.",
},
{
title: "DeerDeer Explains RAG",
description:
"A series of 12 illustrations featuring a curious deer mascot explaining Retrieval-Augmented Generation through a library adventure story.",
},
{
title: "AI Weekly: Your Tech Podcast",
description:
"A 20-minute podcast episode where two AI hosts debate whether AI agents will replace traditional SaaS, based on 5 articles you provided.",
},
{
title: "How Diffusion Models Work",
description:
"A 3-minute animated explainer video visualizing the denoising process, from pure noise to a generated image, with voiceover narration.",
},
];
return (
<Section
className={className}
title="Case Studies"
subtitle="See how DeerFlow is used in the wild"
>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{caseStudies.map((caseStudy) => (
<SpotlightCard className="h-64" key={caseStudy.title}>
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="flex w-75 flex-col gap-4">
<div>
<SparklesIcon className="text-primary size-8" />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-2xl font-bold">{caseStudy.title}</h3>
<p className="text-muted-foreground text-sm">
{caseStudy.description}
</p>
</div>
</div>
</div>
</SpotlightCard>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { AuroraText } from "@/components/ui/aurora-text";
import { Button } from "@/components/ui/button";
import { Section } from "../section";
export function CommunitySection() {
return (
<Section
title={
<AuroraText colors={["#60A5FA", "#A5FA60", "#A560FA"]}>
Join the Community
</AuroraText>
}
subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts."
>
<div className="flex justify-center">
<Button className="text-xl" size="lg" asChild>
<Link href="https://github.com/bytedance/deer-flow" target="_blank">
<GitHubLogoIcon />
Contribute Now
</Link>
</Button>
</div>
</Section>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import {
AnimatedSpan,
Terminal,
TypingAnimation,
} from "@/components/ui/terminal";
import { Section } from "../section";
export function SandboxSection({ className }: { className?: string }) {
return (
<Section
className={className}
title="Sandbox"
subtitle={
<p>
We gave DeerFlow a computer. It can execute code, manage files, and
run long tasks all in a secure Docker sandbox
</p>
}
>
<div className="mt-8 flex w-full max-w-6xl flex-col items-center gap-12 lg:flex-row lg:gap-16">
{/* Left: Terminal */}
<div className="w-full flex-1">
<Terminal className="h-[360px] w-full">
{/* Scene 1: Build a Game */}
<TypingAnimation>$ cat requirements.txt</TypingAnimation>
<AnimatedSpan delay={800} className="text-zinc-400">
pygame==2.5.0
</AnimatedSpan>
<TypingAnimation delay={1200}>
$ pip install -r requirements.txt
</TypingAnimation>
<AnimatedSpan delay={2000} className="text-green-500">
Installed pygame
</AnimatedSpan>
<TypingAnimation delay={2400}>
$ write game.py --lines 156
</TypingAnimation>
<AnimatedSpan delay={3200} className="text-blue-500">
Written 156 lines
</AnimatedSpan>
<TypingAnimation delay={3600}>
$ python game.py --test
</TypingAnimation>
<AnimatedSpan delay={4200} className="text-green-500">
All sprites loaded
</AnimatedSpan>
<AnimatedSpan delay={4500} className="text-green-500">
Physics engine OK
</AnimatedSpan>
<AnimatedSpan delay={4800} className="text-green-500">
60 FPS stable
</AnimatedSpan>
{/* Scene 2: Data Analysis */}
<TypingAnimation delay={5400}>
$ curl -O sales-2024.csv
</TypingAnimation>
<AnimatedSpan delay={6200} className="text-zinc-400">
Downloaded 12.4 MB
</AnimatedSpan>
</Terminal>
</div>
{/* Right: Description */}
<div className="w-full flex-1 space-y-6">
<div className="space-y-4">
<p className="text-sm font-medium tracking-wider text-purple-400 uppercase">
Open-source
</p>
<h2 className="text-4xl font-bold tracking-tight lg:text-5xl">
<a
href="https://github.com/agent-infra/sandbox"
target="_blank"
rel="noopener noreferrer"
>
AIO Sandbox
</a>
</h2>
</div>
<div className="space-y-4 text-lg text-zinc-400">
<p>
We recommend using{" "}
<a
href="https://github.com/agent-infra/sandbox"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
All-in-One Sandbox
</a>{" "}
that combines Browser, Shell, File, MCP and VSCode Server in a
single Docker container.
</p>
</div>
{/* Feature Tags */}
<div className="flex flex-wrap gap-3 pt-4">
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Isolated
</span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Safe
</span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Persistent
</span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Mountable FS
</span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Long-running
</span>
</div>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { cn } from "@/lib/utils";
import ProgressiveSkillsAnimation from "../components/progressive-skills-animation";
import { Section } from "../section";
export function SkillsSection({ className }: { className?: string }) {
return (
<Section
className={cn("h-[calc(100vh-64px)] w-full bg-white/7", className)}
title="Skill-based Architecture"
subtitle={
<div>
Skills are loaded progressively only what&apos;s needed, when
it&apos;s needed.
<br />
Extend DeerFlow with your own skill files, or use our built-in
library.
</div>
}
>
<div className="relative overflow-hidden">
<ProgressiveSkillsAnimation />
</div>
</Section>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import MagicBento from "@/components/ui/magic-bento";
import { cn } from "@/lib/utils";
import { Section } from "../section";
export function WhatsNewSection({ className }: { className?: string }) {
return (
<Section
className={cn("", className)}
title="Whats New in DeerFlow 2.0"
subtitle="DeerFlow is now evolving from a Deep Research agent into a full-stack Super Agent"
>
<div className="flex w-full items-center justify-center">
<MagicBento />
</div>
</Section>
);
}

View File

@@ -1,18 +1,24 @@
"use client"
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface FlickeringGridProps extends React.HTMLAttributes<HTMLDivElement> {
squareSize?: number
gridGap?: number
flickerChance?: number
color?: string
width?: number
height?: number
className?: string
maxOpacity?: number
squareSize?: number;
gridGap?: number;
flickerChance?: number;
color?: string;
width?: number;
height?: number;
className?: string;
maxOpacity?: number;
}
export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
@@ -26,58 +32,58 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
maxOpacity = 0.3,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const memoizedColor = useMemo(() => {
const toRGBA = (color: string) => {
if (typeof window === "undefined") {
return `rgba(0, 0, 0,`
return `rgba(0, 0, 0,`;
}
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const ctx = canvas.getContext("2d")
if (!ctx) return "rgba(255, 0, 0,"
ctx.fillStyle = color
ctx.fillRect(0, 0, 1, 1)
const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data)
return `rgba(${r}, ${g}, ${b},`
}
return toRGBA(color)
}, [color])
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d");
if (!ctx) return "rgba(255, 0, 0,";
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data);
return `rgba(${r}, ${g}, ${b},`;
};
return toRGBA(color);
}, [color]);
const setupCanvas = useCallback(
(canvas: HTMLCanvasElement, width: number, height: number) => {
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const cols = Math.floor(width / (squareSize + gridGap))
const rows = Math.floor(height / (squareSize + gridGap))
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const cols = Math.floor(width / (squareSize + gridGap));
const rows = Math.floor(height / (squareSize + gridGap));
const squares = new Float32Array(cols * rows)
const squares = new Float32Array(cols * rows);
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity
squares[i] = Math.random() * maxOpacity;
}
return { cols, rows, squares, dpr }
return { cols, rows, squares, dpr };
},
[squareSize, gridGap, maxOpacity]
)
[squareSize, gridGap, maxOpacity],
);
const updateSquares = useCallback(
(squares: Float32Array, deltaTime: number) => {
for (let i = 0; i < squares.length; i++) {
if (Math.random() < flickerChance * deltaTime) {
squares[i] = Math.random() * maxOpacity
squares[i] = Math.random() * maxOpacity;
}
}
},
[flickerChance, maxOpacity]
)
[flickerChance, maxOpacity],
);
const drawGrid = useCallback(
(
@@ -87,56 +93,56 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
cols: number,
rows: number,
squares: Float32Array,
dpr: number
dpr: number,
) => {
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = "transparent"
ctx.fillRect(0, 0, width, height)
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "transparent";
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const opacity = squares[i * rows + j]
ctx.fillStyle = `${memoizedColor}${opacity})`
const opacity = squares[i * rows + j];
ctx.fillStyle = `${memoizedColor}${opacity})`;
ctx.fillRect(
i * (squareSize + gridGap) * dpr,
j * (squareSize + gridGap) * dpr,
squareSize * dpr,
squareSize * dpr
)
squareSize * dpr,
);
}
}
},
[memoizedColor, squareSize, gridGap]
)
[memoizedColor, squareSize, gridGap],
);
useEffect(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d")
if (!ctx) return
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationFrameId: number
let gridParams: ReturnType<typeof setupCanvas>
let animationFrameId: number;
let gridParams: ReturnType<typeof setupCanvas>;
const updateCanvasSize = () => {
const newWidth = width || container.clientWidth
const newHeight = height || container.clientHeight
setCanvasSize({ width: newWidth, height: newHeight })
gridParams = setupCanvas(canvas, newWidth, newHeight)
}
const newWidth = width || container.clientWidth;
const newHeight = height || container.clientHeight;
setCanvasSize({ width: newWidth, height: newHeight });
gridParams = setupCanvas(canvas, newWidth, newHeight);
};
updateCanvasSize()
updateCanvasSize();
let lastTime = 0
let lastTime = 0;
const animate = (time: number) => {
if (!isInView) return
if (!isInView) return;
const deltaTime = (time - lastTime) / 1000
lastTime = time
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
updateSquares(gridParams.squares, deltaTime)
updateSquares(gridParams.squares, deltaTime);
drawGrid(
ctx,
canvas.width,
@@ -144,36 +150,38 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
gridParams.cols,
gridParams.rows,
gridParams.squares,
gridParams.dpr
)
animationFrameId = requestAnimationFrame(animate)
}
gridParams.dpr,
);
animationFrameId = requestAnimationFrame(animate);
};
const resizeObserver = new ResizeObserver(() => {
updateCanvasSize()
})
updateCanvasSize();
});
resizeObserver.observe(container)
resizeObserver.observe(container);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
setIsInView(entry.isIntersecting)
if (entry) {
setIsInView(entry.isIntersecting);
}
},
{ threshold: 0 }
)
{ threshold: 0 },
);
intersectionObserver.observe(canvas)
intersectionObserver.observe(canvas);
if (isInView) {
animationFrameId = requestAnimationFrame(animate)
animationFrameId = requestAnimationFrame(animate);
}
return () => {
cancelAnimationFrame(animationFrameId)
resizeObserver.disconnect()
intersectionObserver.disconnect()
}
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView])
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView]);
return (
<div
@@ -190,5 +198,5 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
}}
/>
</div>
)
}
);
};

View File

@@ -1,6 +1,6 @@
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
import { useEffect, useRef } from 'react';
import './Galaxy.css';
import { Renderer, Program, Mesh, Color, Triangle } from "ogl";
import { useEffect, useRef } from "react";
import "./galaxy.css";
const vertexShader = `
attribute vec2 uv;
@@ -200,7 +200,7 @@ export default function Galaxy({
const ctn = ctnDom.current;
const renderer = new Renderer({
alpha: transparent,
premultipliedAlpha: false
premultipliedAlpha: false,
});
const gl = renderer.gl;
@@ -212,6 +212,7 @@ export default function Galaxy({
gl.clearColor(0, 0, 0, 1);
}
/** @type {Program | undefined} */
let program;
function resize() {
@@ -221,11 +222,11 @@ export default function Galaxy({
program.uniforms.uResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
gl.canvas.width / gl.canvas.height,
);
}
}
window.addEventListener('resize', resize, false);
window.addEventListener("resize", resize, false);
resize();
const geometry = new Triangle(gl);
@@ -235,7 +236,11 @@ export default function Galaxy({
uniforms: {
uTime: { value: 0 },
uResolution: {
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
value: new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height,
),
},
uFocal: { value: new Float32Array(focal) },
uRotation: { value: new Float32Array(rotation) },
@@ -244,7 +249,10 @@ export default function Galaxy({
uHueShift: { value: hueShift },
uSpeed: { value: speed },
uMouse: {
value: new Float32Array([smoothMousePos.current.x, smoothMousePos.current.y])
value: new Float32Array([
smoothMousePos.current.x,
smoothMousePos.current.y,
]),
},
uGlowIntensity: { value: glowIntensity },
uSaturation: { value: saturation },
@@ -254,8 +262,8 @@ export default function Galaxy({
uRepulsionStrength: { value: repulsionStrength },
uMouseActiveFactor: { value: 0.0 },
uAutoCenterRepulsion: { value: autoCenterRepulsion },
uTransparent: { value: transparent }
}
uTransparent: { value: transparent },
},
});
const mesh = new Mesh(gl, { geometry, program });
@@ -269,10 +277,13 @@ export default function Galaxy({
}
const lerpFactor = 0.05;
smoothMousePos.current.x += (targetMousePos.current.x - smoothMousePos.current.x) * lerpFactor;
smoothMousePos.current.y += (targetMousePos.current.y - smoothMousePos.current.y) * lerpFactor;
smoothMousePos.current.x +=
(targetMousePos.current.x - smoothMousePos.current.x) * lerpFactor;
smoothMousePos.current.y +=
(targetMousePos.current.y - smoothMousePos.current.y) * lerpFactor;
smoothMouseActive.current += (targetMouseActive.current - smoothMouseActive.current) * lerpFactor;
smoothMouseActive.current +=
(targetMouseActive.current - smoothMouseActive.current) * lerpFactor;
program.uniforms.uMouse.value[0] = smoothMousePos.current.x;
program.uniforms.uMouse.value[1] = smoothMousePos.current.y;
@@ -296,19 +307,19 @@ export default function Galaxy({
}
if (mouseInteraction) {
ctn.addEventListener('mousemove', handleMouseMove);
ctn.addEventListener('mouseleave', handleMouseLeave);
ctn.addEventListener("mousemove", handleMouseMove);
ctn.addEventListener("mouseleave", handleMouseLeave);
}
return () => {
cancelAnimationFrame(animateId);
window.removeEventListener('resize', resize);
window.removeEventListener("resize", resize);
if (mouseInteraction) {
ctn.removeEventListener('mousemove', handleMouseMove);
ctn.removeEventListener('mouseleave', handleMouseLeave);
ctn.removeEventListener("mousemove", handleMouseMove);
ctn.removeEventListener("mouseleave", handleMouseLeave);
}
ctn.removeChild(gl.canvas);
gl.getExtension('WEBGL_lose_context')?.loseContext();
gl.getExtension("WEBGL_lose_context")?.loseContext();
};
}, [
focal,
@@ -326,7 +337,7 @@ export default function Galaxy({
rotationSpeed,
repulsionStrength,
autoCenterRepulsion,
transparent
transparent,
]);
return <div ref={ctnDom} className="galaxy-container" {...rest} />;

View File

@@ -0,0 +1,217 @@
:root {
--hue: 27;
--sat: 69%;
--white: hsl(0, 0%, 100%);
--purple-primary: rgba(132, 0, 255, 1);
--purple-glow: rgba(132, 0, 255, 0.2);
--purple-border: rgba(132, 0, 255, 0.8);
--border-color: #392e4e;
--background-dark: #060010;
color-scheme: light dark;
}
.card-grid {
display: grid;
gap: 0.5em;
padding: 0.75em;
max-width: 54em;
font-size: clamp(1rem, 0.9rem + 0.5vw, 1.5rem);
}
.magic-bento-card {
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
aspect-ratio: 4/3;
min-height: 200px;
width: 100%;
max-width: 100%;
padding: 1.25em;
border-radius: 20px;
border: 1px solid var(--border-color);
background: var(--background-dark);
font-weight: 300;
overflow: hidden;
transition: all 0.3s ease;
--glow-x: 50%;
--glow-y: 50%;
--glow-intensity: 0;
--glow-radius: 200px;
}
.magic-bento-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.magic-bento-card__header,
.magic-bento-card__content {
display: flex;
position: relative;
color: var(--white);
}
.magic-bento-card__header {
gap: 0.75em;
justify-content: space-between;
}
.magic-bento-card__content {
flex-direction: column;
}
.magic-bento-card__label {
font-size: 16px;
}
.magic-bento-card__title,
.magic-bento-card__description {
--clamp-title: 1;
--clamp-desc: 2;
}
.magic-bento-card__title {
font-weight: 400;
font-size: 16px;
margin: 0 0 0.25em;
}
.magic-bento-card__description {
font-size: 12px;
line-height: 1.2;
opacity: 0.9;
}
.magic-bento-card--text-autohide .magic-bento-card__title,
.magic-bento-card--text-autohide .magic-bento-card__description {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.magic-bento-card--text-autohide .magic-bento-card__title {
-webkit-line-clamp: var(--clamp-title);
line-clamp: var(--clamp-title);
}
.magic-bento-card--text-autohide .magic-bento-card__description {
-webkit-line-clamp: var(--clamp-desc);
line-clamp: var(--clamp-desc);
}
@media (max-width: 599px) {
.card-grid {
grid-template-columns: 1fr;
width: 90%;
margin: 0 auto;
padding: 0.5em;
}
.magic-bento-card {
width: 100%;
min-height: 180px;
}
}
@media (min-width: 600px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.card-grid {
grid-template-columns: repeat(4, 1fr);
}
.magic-bento-card:nth-child(3) {
grid-column: span 2;
grid-row: span 2;
}
.magic-bento-card:nth-child(4) {
grid-column: 1 / span 2;
grid-row: 2 / span 2;
}
.magic-bento-card:nth-child(6) {
grid-column: 4;
grid-row: 3;
}
}
/* Border glow effect */
.magic-bento-card--border-glow::after {
content: '';
position: absolute;
inset: 0;
padding: 6px;
background: radial-gradient(
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
transparent 60%
);
border-radius: inherit;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
opacity: 1;
transition: opacity 0.3s ease;
z-index: 1;
}
.magic-bento-card--border-glow:hover::after {
opacity: 1;
}
.magic-bento-card--border-glow:hover {
box-shadow:
0 4px 20px rgba(46, 24, 78, 0.4),
0 0 30px var(--purple-glow);
}
.particle-container {
position: relative;
overflow: hidden;
}
.particle::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: rgba(132, 0, 255, 0.2);
border-radius: 50%;
z-index: -1;
}
.particle-container:hover {
box-shadow:
0 4px 20px rgba(46, 24, 78, 0.2),
0 0 30px var(--purple-glow);
}
/* Global spotlight styles */
.global-spotlight {
mix-blend-mode: screen;
will-change: transform, opacity;
z-index: 200 !important;
pointer-events: none;
}
.bento-section {
position: relative;
user-select: none;
}

View File

@@ -0,0 +1,757 @@
import { gsap } from "gsap";
import React, { useRef, useEffect, useCallback, useState } from "react";
import "./magic-bento.css";
export interface BentoCardProps {
color?: string;
title?: React.ReactNode;
description?: React.ReactNode;
label?: React.ReactNode;
textAutoHide?: boolean;
disableAnimations?: boolean;
}
export interface BentoProps {
textAutoHide?: boolean;
enableStars?: boolean;
enableSpotlight?: boolean;
enableBorderGlow?: boolean;
disableAnimations?: boolean;
spotlightRadius?: number;
particleCount?: number;
enableTilt?: boolean;
glowColor?: string;
clickEffect?: boolean;
enableMagnetism?: boolean;
}
const DEFAULT_PARTICLE_COUNT = 12;
const DEFAULT_SPOTLIGHT_RADIUS = 300;
const DEFAULT_GLOW_COLOR = "132, 0, 255";
const MOBILE_BREAKPOINT = 768;
const cardData: BentoCardProps[] = [
{
color: "#0a0015",
title: "Long/Short-term Memory",
description: (
<div>
<div>Now the agent can better understand you</div>
<div className="text-muted-foreground">Coming soon</div>
</div>
),
label: "Context Engineering",
},
{
color: "#0a0015",
title: "Planning and Reasoning",
description: "Plans ahead, reasons through complexity, then acts",
label: "Long Task Running",
},
{
color: "#0a0015",
title: "Skills and Tools",
description:
"Plug, play, or even swap built-in tools. Build the agent you want.",
label: "Extensible",
},
{
color: "#0a0015",
title: "Sandbox with File System",
description: "Read, write, run — like a real computer",
label: "Persistent",
},
{
color: "#0a0015",
title: "Multi-Model Support",
description: "Doubao, DeepSeek, OpenAI, Gemini, etc.",
label: "Flexible",
},
{
color: "#0a0015",
title: "Open Source",
description: "MIT License, self-hosted, full control",
label: "Free",
},
];
const createParticleElement = (
x: number,
y: number,
color: string = DEFAULT_GLOW_COLOR,
): HTMLDivElement => {
const el = document.createElement("div");
el.className = "particle";
el.style.cssText = `
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(${color}, 1);
box-shadow: 0 0 6px rgba(${color}, 0.6);
pointer-events: none;
z-index: 100;
left: ${x}px;
top: ${y}px;
`;
return el;
};
const calculateSpotlightValues = (radius: number) => ({
proximity: radius * 0.5,
fadeDistance: radius * 0.75,
});
const updateCardGlowProperties = (
card: HTMLElement,
mouseX: number,
mouseY: number,
glow: number,
radius: number,
) => {
const rect = card.getBoundingClientRect();
const relativeX = ((mouseX - rect.left) / rect.width) * 100;
const relativeY = ((mouseY - rect.top) / rect.height) * 100;
card.style.setProperty("--glow-x", `${relativeX}%`);
card.style.setProperty("--glow-y", `${relativeY}%`);
card.style.setProperty("--glow-intensity", glow.toString());
card.style.setProperty("--glow-radius", `${radius}px`);
};
const ParticleCard: React.FC<{
children: React.ReactNode;
className?: string;
disableAnimations?: boolean;
style?: React.CSSProperties;
particleCount?: number;
glowColor?: string;
enableTilt?: boolean;
clickEffect?: boolean;
enableMagnetism?: boolean;
}> = ({
children,
className = "",
disableAnimations = false,
style,
particleCount = DEFAULT_PARTICLE_COUNT,
glowColor = DEFAULT_GLOW_COLOR,
enableTilt = true,
clickEffect = false,
enableMagnetism = false,
}) => {
const cardRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<HTMLDivElement[]>([]);
const timeoutsRef = useRef<number[]>([]);
const isHoveredRef = useRef(false);
const memoizedParticles = useRef<HTMLDivElement[]>([]);
const particlesInitialized = useRef(false);
const magnetismAnimationRef = useRef<gsap.core.Tween | null>(null);
const initializeParticles = useCallback(() => {
if (particlesInitialized.current || !cardRef.current) return;
const { width, height } = cardRef.current.getBoundingClientRect();
memoizedParticles.current = Array.from({ length: particleCount }, () =>
createParticleElement(
Math.random() * width,
Math.random() * height,
glowColor,
),
);
particlesInitialized.current = true;
}, [particleCount, glowColor]);
const clearAllParticles = useCallback(() => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current = [];
magnetismAnimationRef.current?.kill();
particlesRef.current.forEach((particle) => {
gsap.to(particle, {
scale: 0,
opacity: 0,
duration: 0.3,
ease: "back.in(1.7)",
onComplete: () => {
particle.parentNode?.removeChild(particle);
},
});
});
particlesRef.current = [];
}, []);
const animateParticles = useCallback(() => {
if (!cardRef.current || !isHoveredRef.current) return;
if (!particlesInitialized.current) {
initializeParticles();
}
memoizedParticles.current.forEach((particle, index) => {
const timeoutId = setTimeout(() => {
if (!isHoveredRef.current || !cardRef.current) return;
const clone = particle.cloneNode(true) as HTMLDivElement;
cardRef.current.appendChild(clone);
particlesRef.current.push(clone);
gsap.fromTo(
clone,
{ scale: 0, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.3, ease: "back.out(1.7)" },
);
gsap.to(clone, {
x: (Math.random() - 0.5) * 100,
y: (Math.random() - 0.5) * 100,
rotation: Math.random() * 360,
duration: 2 + Math.random() * 2,
ease: "none",
repeat: -1,
yoyo: true,
});
gsap.to(clone, {
opacity: 0.3,
duration: 1.5,
ease: "power2.inOut",
repeat: -1,
yoyo: true,
});
}, index * 100);
timeoutsRef.current.push(timeoutId as unknown as number);
});
}, [initializeParticles]);
useEffect(() => {
if (disableAnimations || !cardRef.current) return;
const element = cardRef.current;
const handleMouseEnter = () => {
isHoveredRef.current = true;
animateParticles();
if (enableTilt) {
gsap.to(element, {
rotateX: 5,
rotateY: 5,
duration: 0.3,
ease: "power2.out",
transformPerspective: 1000,
});
}
};
const handleMouseLeave = () => {
isHoveredRef.current = false;
clearAllParticles();
if (enableTilt) {
gsap.to(element, {
rotateX: 0,
rotateY: 0,
duration: 0.3,
ease: "power2.out",
});
}
if (enableMagnetism) {
gsap.to(element, {
x: 0,
y: 0,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!enableTilt && !enableMagnetism) return;
const rect = element.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
if (enableTilt) {
const rotateX = ((y - centerY) / centerY) * -10;
const rotateY = ((x - centerX) / centerX) * 10;
gsap.to(element, {
rotateX,
rotateY,
duration: 0.1,
ease: "power2.out",
transformPerspective: 1000,
});
}
if (enableMagnetism) {
const magnetX = (x - centerX) * 0.05;
const magnetY = (y - centerY) * 0.05;
magnetismAnimationRef.current = gsap.to(element, {
x: magnetX,
y: magnetY,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleClick = (e: MouseEvent) => {
if (!clickEffect) return;
const rect = element.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const maxDistance = Math.max(
Math.hypot(x, y),
Math.hypot(x - rect.width, y),
Math.hypot(x, y - rect.height),
Math.hypot(x - rect.width, y - rect.height),
);
const ripple = document.createElement("div");
ripple.style.cssText = `
position: absolute;
width: ${maxDistance * 2}px;
height: ${maxDistance * 2}px;
border-radius: 50%;
background: radial-gradient(circle, rgba(${glowColor}, 0.4) 0%, rgba(${glowColor}, 0.2) 30%, transparent 70%);
left: ${x - maxDistance}px;
top: ${y - maxDistance}px;
pointer-events: none;
z-index: 1000;
`;
element.appendChild(ripple);
gsap.fromTo(
ripple,
{
scale: 0,
opacity: 1,
},
{
scale: 1,
opacity: 0,
duration: 0.8,
ease: "power2.out",
onComplete: () => ripple.remove(),
},
);
};
element.addEventListener("mouseenter", handleMouseEnter);
element.addEventListener("mouseleave", handleMouseLeave);
element.addEventListener("mousemove", handleMouseMove);
element.addEventListener("click", handleClick);
return () => {
isHoveredRef.current = false;
element.removeEventListener("mouseenter", handleMouseEnter);
element.removeEventListener("mouseleave", handleMouseLeave);
element.removeEventListener("mousemove", handleMouseMove);
element.removeEventListener("click", handleClick);
clearAllParticles();
};
}, [
animateParticles,
clearAllParticles,
disableAnimations,
enableTilt,
enableMagnetism,
clickEffect,
glowColor,
]);
return (
<div
ref={cardRef}
className={`${className} particle-container`}
style={{ ...style, position: "relative", overflow: "hidden" }}
>
{children}
</div>
);
};
const GlobalSpotlight: React.FC<{
gridRef: React.RefObject<HTMLDivElement | null>;
disableAnimations?: boolean;
enabled?: boolean;
spotlightRadius?: number;
glowColor?: string;
}> = ({
gridRef,
disableAnimations = false,
enabled = true,
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
glowColor = DEFAULT_GLOW_COLOR,
}) => {
const spotlightRef = useRef<HTMLDivElement | null>(null);
const isInsideSection = useRef(false);
useEffect(() => {
if (disableAnimations || !gridRef?.current || !enabled) return;
const spotlight = document.createElement("div");
spotlight.className = "global-spotlight";
spotlight.style.cssText = `
position: fixed;
width: 800px;
height: 800px;
border-radius: 50%;
pointer-events: none;
background: radial-gradient(circle,
rgba(${glowColor}, 0.15) 0%,
rgba(${glowColor}, 0.08) 15%,
rgba(${glowColor}, 0.04) 25%,
rgba(${glowColor}, 0.02) 40%,
rgba(${glowColor}, 0.01) 65%,
transparent 70%
);
z-index: 200;
opacity: 0;
transform: translate(-50%, -50%);
mix-blend-mode: screen;
`;
document.body.appendChild(spotlight);
spotlightRef.current = spotlight;
const handleMouseMove = (e: MouseEvent) => {
if (!spotlightRef.current || !gridRef.current) return;
const section = gridRef.current.closest(".bento-section");
const rect = section?.getBoundingClientRect();
const mouseInside =
rect &&
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
isInsideSection.current = mouseInside ?? false;
const cards = gridRef.current.querySelectorAll(".magic-bento-card");
if (!mouseInside) {
gsap.to(spotlightRef.current, {
opacity: 0,
duration: 0.3,
ease: "power2.out",
});
cards.forEach((card) => {
(card as HTMLElement).style.setProperty("--glow-intensity", "0");
});
return;
}
const { proximity, fadeDistance } =
calculateSpotlightValues(spotlightRadius);
let minDistance = Infinity;
cards.forEach((card) => {
const cardElement = card as HTMLElement;
const cardRect = cardElement.getBoundingClientRect();
const centerX = cardRect.left + cardRect.width / 2;
const centerY = cardRect.top + cardRect.height / 2;
const distance =
Math.hypot(e.clientX - centerX, e.clientY - centerY) -
Math.max(cardRect.width, cardRect.height) / 2;
const effectiveDistance = Math.max(0, distance);
minDistance = Math.min(minDistance, effectiveDistance);
let glowIntensity = 0;
if (effectiveDistance <= proximity) {
glowIntensity = 1;
} else if (effectiveDistance <= fadeDistance) {
glowIntensity =
(fadeDistance - effectiveDistance) / (fadeDistance - proximity);
}
updateCardGlowProperties(
cardElement,
e.clientX,
e.clientY,
glowIntensity,
spotlightRadius,
);
});
gsap.to(spotlightRef.current, {
left: e.clientX,
top: e.clientY,
duration: 0.1,
ease: "power2.out",
});
const targetOpacity =
minDistance <= proximity
? 0.8
: minDistance <= fadeDistance
? ((fadeDistance - minDistance) / (fadeDistance - proximity)) * 0.8
: 0;
gsap.to(spotlightRef.current, {
opacity: targetOpacity,
duration: targetOpacity > 0 ? 0.2 : 0.5,
ease: "power2.out",
});
};
const handleMouseLeave = () => {
isInsideSection.current = false;
gridRef.current?.querySelectorAll(".magic-bento-card").forEach((card) => {
(card as HTMLElement).style.setProperty("--glow-intensity", "0");
});
if (spotlightRef.current) {
gsap.to(spotlightRef.current, {
opacity: 0,
duration: 0.3,
ease: "power2.out",
});
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseleave", handleMouseLeave);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseleave", handleMouseLeave);
spotlightRef.current?.parentNode?.removeChild(spotlightRef.current);
};
}, [gridRef, disableAnimations, enabled, spotlightRadius, glowColor]);
return null;
};
const BentoCardGrid: React.FC<{
children: React.ReactNode;
gridRef?: React.RefObject<HTMLDivElement | null>;
}> = ({ children, gridRef }) => (
<div className="card-grid bento-section" ref={gridRef}>
{children}
</div>
);
const useMobileDetection = () => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () =>
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return isMobile;
};
const MagicBento: React.FC<BentoProps> = ({
textAutoHide = true,
enableStars = true,
enableSpotlight = true,
enableBorderGlow = true,
disableAnimations = false,
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
particleCount = DEFAULT_PARTICLE_COUNT,
enableTilt = false,
glowColor = DEFAULT_GLOW_COLOR,
clickEffect = true,
enableMagnetism = true,
}) => {
const gridRef = useRef<HTMLDivElement>(null);
const isMobile = useMobileDetection();
const shouldDisableAnimations = disableAnimations || isMobile;
return (
<>
{enableSpotlight && (
<GlobalSpotlight
gridRef={gridRef}
disableAnimations={shouldDisableAnimations}
enabled={enableSpotlight}
spotlightRadius={spotlightRadius}
glowColor={glowColor}
/>
)}
<BentoCardGrid gridRef={gridRef}>
{cardData.map((card, index) => {
const baseClassName = `magic-bento-card ${textAutoHide ? "magic-bento-card--text-autohide" : ""} ${enableBorderGlow ? "magic-bento-card--border-glow" : ""}`;
const cardProps = {
className: baseClassName,
style: {
backgroundColor: card.color,
"--glow-color": glowColor,
} as React.CSSProperties,
};
if (enableStars) {
return (
<ParticleCard
key={index}
{...cardProps}
disableAnimations={shouldDisableAnimations}
particleCount={particleCount}
glowColor={glowColor}
enableTilt={enableTilt}
clickEffect={clickEffect}
enableMagnetism={enableMagnetism}
>
<div className="magic-bento-card__header">
<div className="magic-bento-card__label">{card.label}</div>
</div>
<div className="magic-bento-card__content">
<h2 className="magic-bento-card__title">{card.title}</h2>
<div className="magic-bento-card__description">
{card.description}
</div>
</div>
</ParticleCard>
);
}
return (
<div
key={index}
{...cardProps}
ref={(el) => {
if (!el) return;
const handleMouseMove = (e: MouseEvent) => {
if (shouldDisableAnimations) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
if (enableTilt) {
const rotateX = ((y - centerY) / centerY) * -10;
const rotateY = ((x - centerX) / centerX) * 10;
gsap.to(el, {
rotateX,
rotateY,
duration: 0.1,
ease: "power2.out",
transformPerspective: 1000,
});
}
if (enableMagnetism) {
const magnetX = (x - centerX) * 0.05;
const magnetY = (y - centerY) * 0.05;
gsap.to(el, {
x: magnetX,
y: magnetY,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleMouseLeave = () => {
if (shouldDisableAnimations) return;
if (enableTilt) {
gsap.to(el, {
rotateX: 0,
rotateY: 0,
duration: 0.3,
ease: "power2.out",
});
}
if (enableMagnetism) {
gsap.to(el, {
x: 0,
y: 0,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleClick = (e: MouseEvent) => {
if (!clickEffect || shouldDisableAnimations) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Calculate the maximum distance from click point to any corner
const maxDistance = Math.max(
Math.hypot(x, y),
Math.hypot(x - rect.width, y),
Math.hypot(x, y - rect.height),
Math.hypot(x - rect.width, y - rect.height),
);
const ripple = document.createElement("div");
ripple.style.cssText = `
position: absolute;
width: ${maxDistance * 2}px;
height: ${maxDistance * 2}px;
border-radius: 50%;
background: radial-gradient(circle, rgba(${glowColor}, 0.4) 0%, rgba(${glowColor}, 0.2) 30%, transparent 70%);
left: ${x - maxDistance}px;
top: ${y - maxDistance}px;
pointer-events: none;
z-index: 1000;
`;
el.appendChild(ripple);
gsap.fromTo(
ripple,
{
scale: 0,
opacity: 1,
},
{
scale: 1,
opacity: 0,
duration: 0.8,
ease: "power2.out",
onComplete: () => ripple.remove(),
},
);
};
el.addEventListener("mousemove", handleMouseMove);
el.addEventListener("mouseleave", handleMouseLeave);
el.addEventListener("click", handleClick);
}}
>
<div className="magic-bento-card__header">
<div className="magic-bento-card__label">{card.label}</div>
</div>
<div className="magic-bento-card__content">
<h2 className="magic-bento-card__title">{card.title}</h2>
<p className="magic-bento-card__description">
{card.description}
</p>
</div>
</div>
);
})}
</BentoCardGrid>
</>
);
};
export default MagicBento;

View File

@@ -1,16 +1,16 @@
"use client"
"use client";
import { ComponentPropsWithoutRef, useEffect, useRef } from "react"
import { useInView, useMotionValue, useSpring } from "motion/react"
import { type ComponentPropsWithoutRef, useEffect, useRef } from "react";
import { useInView, useMotionValue, useSpring } from "motion/react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
value: number
startValue?: number
direction?: "up" | "down"
delay?: number
decimalPlaces?: number
value: number;
startValue?: number;
direction?: "up" | "down";
delay?: number;
decimalPlaces?: number;
}
export function NumberTicker({
@@ -22,22 +22,22 @@ export function NumberTicker({
decimalPlaces = 0,
...props
}: NumberTickerProps) {
const ref = useRef<HTMLSpanElement>(null)
const motionValue = useMotionValue(direction === "down" ? value : startValue)
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? value : startValue);
const springValue = useSpring(motionValue, {
damping: 60,
stiffness: 100,
})
const isInView = useInView(ref, { once: true, margin: "0px" })
});
const isInView = useInView(ref, { once: true, margin: "0px" });
useEffect(() => {
if (isInView) {
const timer = setTimeout(() => {
motionValue.set(direction === "down" ? startValue : value)
}, delay * 1000)
return () => clearTimeout(timer)
motionValue.set(direction === "down" ? startValue : value);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [motionValue, isInView, delay, value, direction, startValue])
}, [motionValue, isInView, delay, value, direction, startValue]);
useEffect(
() =>
@@ -46,22 +46,22 @@ export function NumberTicker({
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)))
}).format(Number(latest.toFixed(decimalPlaces)));
}
}),
[springValue, decimalPlaces]
)
[springValue, decimalPlaces],
);
return (
<span
ref={ref}
className={cn(
"inline-block tracking-wider text-black tabular-nums dark:text-white",
className
className,
)}
{...props}
>
{startValue}
</span>
)
);
}

View File

@@ -0,0 +1,29 @@
.card-spotlight {
position: relative;
border-radius: 1.5rem;
border: 1px solid #222;
background-color: #111;
padding: 2rem;
overflow: hidden;
--mouse-x: 50%;
--mouse-y: 50%;
--spotlight-color: rgba(255, 255, 255, 0.05);
}
.card-spotlight::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at var(--mouse-x) var(--mouse-y), var(--spotlight-color), transparent 80%);
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
.card-spotlight:hover::before,
.card-spotlight:focus-within::before {
opacity: 0.6;
}

View File

@@ -0,0 +1,46 @@
"use client";
import React, { useRef } from "react";
import "./spotlight-card.css";
interface Position {
x: number;
y: number;
}
interface SpotlightCardProps extends React.PropsWithChildren {
className?: string;
spotlightColor?: `rgba(${number}, ${number}, ${number}, ${number})`;
}
const SpotlightCard: React.FC<SpotlightCardProps> = ({
children,
className = "",
spotlightColor = "rgba(255, 255, 255, 0.25)",
}) => {
const divRef = useRef<HTMLDivElement>(null);
const handleMouseMove: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!divRef.current) return;
const rect = divRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
divRef.current.style.setProperty("--mouse-x", `${x}px`);
divRef.current.style.setProperty("--mouse-y", `${y}px`);
divRef.current.style.setProperty("--spotlight-color", spotlightColor);
};
return (
<div
ref={divRef}
onMouseMove={handleMouseMove}
className={`card-spotlight ${className}`}
>
{children}
</div>
);
};
export default SpotlightCard;

View File

@@ -0,0 +1,257 @@
"use client";
import {
Children,
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { motion, type MotionProps, useInView } from "motion/react";
import { cn } from "@/lib/utils";
interface SequenceContextValue {
completeItem: (index: number) => void;
activeIndex: number;
sequenceStarted: boolean;
}
const SequenceContext = createContext<SequenceContextValue | null>(null);
const useSequence = () => useContext(SequenceContext);
const ItemIndexContext = createContext<number | null>(null);
const useItemIndex = () => useContext(ItemIndexContext);
interface AnimatedSpanProps extends MotionProps {
children: React.ReactNode;
delay?: number;
className?: string;
startOnView?: boolean;
}
export const AnimatedSpan = ({
children,
delay = 0,
className,
startOnView = false,
...props
}: AnimatedSpanProps) => {
const elementRef = useRef<HTMLDivElement | null>(null);
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
});
const sequence = useSequence();
const itemIndex = useItemIndex();
const [hasStarted, setHasStarted] = useState(false);
useEffect(() => {
if (!sequence || itemIndex === null) return;
if (!sequence.sequenceStarted) return;
if (hasStarted) return;
if (sequence.activeIndex === itemIndex) {
setHasStarted(true);
}
}, [sequence?.activeIndex, sequence?.sequenceStarted, hasStarted, itemIndex]);
const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true;
return (
<motion.div
ref={elementRef}
initial={{ opacity: 0, y: -5 }}
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
transition={{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }}
className={cn("grid text-sm font-normal tracking-tight", className)}
onAnimationComplete={() => {
if (!sequence) return;
if (itemIndex === null) return;
sequence.completeItem(itemIndex);
}}
{...props}
>
{children}
</motion.div>
);
};
interface TypingAnimationProps extends MotionProps {
children: string;
className?: string;
duration?: number;
delay?: number;
as?: React.ElementType;
startOnView?: boolean;
}
export const TypingAnimation = ({
children,
className,
duration = 60,
delay = 0,
as: Component = "span",
startOnView = true,
...props
}: TypingAnimationProps) => {
if (typeof children !== "string") {
throw new Error("TypingAnimation: children must be a string. Received:");
}
const MotionComponent = useMemo(
() =>
motion.create(Component, {
forwardMotionProps: true,
}),
[Component],
);
const [displayedText, setDisplayedText] = useState<string>("");
const [started, setStarted] = useState(false);
const elementRef = useRef<HTMLElement | null>(null);
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
});
const sequence = useSequence();
const itemIndex = useItemIndex();
useEffect(() => {
if (sequence && itemIndex !== null) {
if (!sequence.sequenceStarted) return;
if (started) return;
if (sequence.activeIndex === itemIndex) {
setStarted(true);
}
return;
}
if (!startOnView) {
const startTimeout = setTimeout(() => setStarted(true), delay);
return () => clearTimeout(startTimeout);
}
if (!isInView) return;
const startTimeout = setTimeout(() => setStarted(true), delay);
return () => clearTimeout(startTimeout);
}, [
delay,
startOnView,
isInView,
started,
sequence?.activeIndex,
sequence?.sequenceStarted,
itemIndex,
]);
useEffect(() => {
if (!started) return;
let i = 0;
const typingEffect = setInterval(() => {
if (i < children.length) {
setDisplayedText(children.substring(0, i + 1));
i++;
} else {
clearInterval(typingEffect);
if (sequence && itemIndex !== null) {
sequence.completeItem(itemIndex);
}
}
}, duration);
return () => {
clearInterval(typingEffect);
};
}, [children, duration, started]);
return (
<MotionComponent
ref={elementRef}
className={cn("text-sm font-normal tracking-tight", className)}
{...props}
>
{displayedText}
</MotionComponent>
);
};
interface TerminalProps {
children: React.ReactNode;
className?: string;
sequence?: boolean;
startOnView?: boolean;
}
export const Terminal = ({
children,
className,
sequence = true,
startOnView = true,
}: TerminalProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const isInView = useInView(containerRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
});
const [activeIndex, setActiveIndex] = useState(0);
const sequenceHasStarted = sequence ? !startOnView || isInView : false;
const contextValue = useMemo<SequenceContextValue | null>(() => {
if (!sequence) return null;
return {
completeItem: (index: number) => {
setActiveIndex((current) =>
index === current ? current + 1 : current,
);
},
activeIndex,
sequenceStarted: sequenceHasStarted,
};
}, [sequence, activeIndex, sequenceHasStarted]);
const wrappedChildren = useMemo(() => {
if (!sequence) return children;
const array = Children.toArray(children);
return array.map((child, index) => (
<ItemIndexContext.Provider key={index} value={index}>
{child as React.ReactNode}
</ItemIndexContext.Provider>
));
}, [children, sequence]);
const content = (
<div
ref={containerRef}
className={cn(
"border-border bg-background z-0 h-full max-h-[400px] w-full max-w-lg rounded-xl border",
className,
)}
>
<div className="border-border flex flex-col gap-y-2 border-b p-4">
<div className="flex flex-row gap-x-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
<div className="h-2 w-2 rounded-full bg-green-500"></div>
</div>
</div>
<pre className="p-4">
<code className="grid gap-y-1 overflow-auto">{wrappedChildren}</code>
</pre>
</div>
);
if (!sequence) return content;
return (
<SequenceContext.Provider value={contextValue}>
{content}
</SequenceContext.Provider>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, MotionProps } from "motion/react";
import { AnimatePresence, motion, type MotionProps } from "motion/react";
import { cn } from "@/lib/utils";
import { AuroraText } from "./aurora-text";
@@ -15,12 +15,12 @@ interface WordRotateProps {
export function WordRotate({
words,
duration = 2500,
duration = 2200,
motionProps = {
initial: { opacity: 0, y: -50, filter: "blur(16px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: 50, filter: "blur(16px)" },
transition: { duration: 0.25, ease: "easeOut" },
transition: { duration: 0.3, ease: "easeOut" },
},
className,
}: WordRotateProps) {
@@ -37,7 +37,7 @@ export function WordRotate({
return (
<div className="overflow-hidden py-2">
<AnimatePresence mode="wait">
<AnimatePresence mode="popLayout">
<motion.h1
key={words[index]}
className={cn(className)}

View File

@@ -49,10 +49,11 @@ export function MessageList({
);
}
if (group.type === "assistant:present-files") {
const files = [];
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
files.push(...extractPresentFilesFromMessage(message));
const presentFiles = extractPresentFilesFromMessage(message);
files.push(...presentFiles);
}
}
return (

View File

@@ -12,13 +12,10 @@
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
"noImplicitAny": false,
"checkJs": false,
/* Bundled projects */
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
@@ -32,9 +29,7 @@
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
},
"include": [
@@ -46,8 +41,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"generated"
]
"exclude": ["node_modules", "generated"]
}