diff --git a/frontend/package.json b/frontend/package.json index d0dd49e..a982acd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c888254..5ac8e1e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 50c4615..e5d3187 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 (
-
-
-

DeerFlow

-
-
-
- -
-
-
-
- +
+
+ + + + + +
-
+
); } - -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 ( - <> - - {stars && ( - - )} - - ); -} diff --git a/frontend/src/components/landing/components/progressive-skills-animation.tsx b/frontend/src/components/landing/components/progressive-skills-animation.tsx new file mode 100644 index 0000000..021ee48 --- /dev/null +++ b/frontend/src/components/landing/components/progressive-skills-animation.tsx @@ -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("idle"); + const [searchIndex, setSearchIndex] = useState(0); + const [buildIndex, setBuildIndex] = useState(0); + const [, setChatMessages] = useState([]); + const [, setShowWorkspace] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [hasPlayed, setHasPlayed] = useState(false); + const [hasAutoPlayed, setHasAutoPlayed] = useState(false); + const chatMessagesRef = useRef(null); + const containerRef = useRef(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 ( +
+ {/* Overlay and Play Button */} + + {!isPlaying && ( + + +
+ +
+ + {hasPlayed ? "Click to replay" : "Click to play"} + +
+
+ )} +
+ +
+ {/* Left: File Tree */} +
+ + /mnt/skills/ + + +
+ {getFileTree().map((item, index) => ( + + {item.type === "folder" ? ( + + ) : ( + + )} + {item.name} + {item.done && } + {item.highlight && !item.done && ( + + )} + + ))} +
+
+ + {/* Right: Chat Interface */} +
+ {/* Chat Header */} +
+
+
+ DeerFlow Agent +
+
+ + {/* Chat Messages */} +
+ {/* User Message */} + + {phase !== "idle" && ( + +
+

+ Research mRNA delivery, build a landing page, deploy to + Vercel +

+
+
+ )} +
+ + {/* Agent Messages */} + + {phase !== "idle" && phase !== "user-input" && ( + + {/* Found Skills */} + {[ + "scanning", + "load-skill", + "load-template", + "researching", + "load-frontend", + "building", + "load-deploy", + "deploying", + "done", + ].includes(phase) && ( +
+ Found 3 skills +
+ )} + + {/* Researching Section */} + {[ + "load-skill", + "load-template", + "researching", + "load-frontend", + "building", + "load-deploy", + "deploying", + "done", + ].includes(phase) && ( +
+
+
+ 🔬 Researching... +
+
+ {/* Loading SKILL.md */} + {[ + "load-skill", + "load-template", + "researching", + "load-frontend", + "building", + "load-deploy", + "deploying", + "done", + ].includes(phase) && ( +
+ + Loading deep-search/SKILL.md... +
+ )} + {/* Loading biotech.md */} + {[ + "load-template", + "researching", + "load-frontend", + "building", + "load-deploy", + "deploying", + "done", + ].includes(phase) && ( +
+ + + Found biotech related topic, loading + deep-search/biotech.md... + +
+ )} +
+ {/* Search steps */} + {phase === "researching" && ( +
+ {searchSteps.slice(0, searchIndex).map((step, i) => ( + + {step.type === "search" ? ( + + ) : ( + + )} + {step.text} + + ))} +
+ )} + {[ + "load-frontend", + "building", + "load-deploy", + "deploying", + "done", + ].includes(phase) && ( +
+ {searchSteps.map((step, i) => ( + + {step.type === "search" ? ( + + ) : ( + + )} + {step.text} + + ))} +
+ )} +
+ )} + + {/* Building */} + {["building", "load-deploy", "deploying", "done"].includes( + phase, + ) && ( + +
+
🔨 Building...
+
+ + Loading frontend-design/SKILL.md... +
+
+ {workspaceFiles.slice(0, buildIndex).map((file) => ( + + + {file} + + + ))} +
+
+ )} + + {/* Deploying */} + {["load-deploy", "deploying", "done"].includes(phase) && ( + +
+
🚀 Deploying...
+
+
+ + Loading deploy/SKILL.md... +
+ {["deploying", "done"].includes(phase) && ( + + + Executing deploy.sh + + )} +
+ {phase === "done" && ( + +
+ ✅ Live at biotech-startup.vercel.app +
+
+ )} +
+ )} +
+ )} +
+
+ + {/* Chat Input (decorative) */} +
+
+ Ask DeerFlow anything... +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/landing/footer.tsx b/frontend/src/components/landing/footer.tsx new file mode 100644 index 0000000..4176fb3 --- /dev/null +++ b/frontend/src/components/landing/footer.tsx @@ -0,0 +1,19 @@ +import { useMemo } from "react"; + +export function Footer() { + const year = useMemo(() => new Date().getFullYear(), []); + return ( +
+
+
+

+ "Originated from Open Source, give back to Open Source." +

+
+
+

Licensed under MIT License

+

© {year} DeerFlow

+
+
+ ); +} diff --git a/frontend/src/components/landing/header.tsx b/frontend/src/components/landing/header.tsx new file mode 100644 index 0000000..7e4afa4 --- /dev/null +++ b/frontend/src/components/landing/header.tsx @@ -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 ( +
+ +
+ ); +} + +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 ( + <> + + {stars && ( + + )} + + ); +} diff --git a/frontend/src/components/landing/jumbotron.tsx b/frontend/src/components/landing/hero.tsx similarity index 69% rename from frontend/src/components/landing/jumbotron.tsx rename to frontend/src/components/landing/hero.tsx index f978e65..37ffc92 100644 --- a/frontend/src/components/landing/jumbotron.tsx +++ b/frontend/src/components/landing/hero.tsx @@ -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 (
-
+

{" "}
with DeerFlow

-

+

DeerFlow is an open-source SuperAgent that researches, codes, and
creates. With the help of sandboxes, tools and skills, it handles
different levels of tasks that could take minutes to hours.

- + + +
); diff --git a/frontend/src/components/landing/section.tsx b/frontend/src/components/landing/section.tsx new file mode 100644 index 0000000..9714669 --- /dev/null +++ b/frontend/src/components/landing/section.tsx @@ -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 ( +
+
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+
{children}
+
+ ); +} diff --git a/frontend/src/components/landing/sections/case-study-section.tsx b/frontend/src/components/landing/sections/case-study-section.tsx new file mode 100644 index 0000000..d3f6464 --- /dev/null +++ b/frontend/src/components/landing/sections/case-study-section.tsx @@ -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 ( +
+
+ {caseStudies.map((caseStudy) => ( + +
+
+
+ +
+
+

{caseStudy.title}

+

+ {caseStudy.description} +

+
+
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/landing/sections/community-section.tsx b/frontend/src/components/landing/sections/community-section.tsx new file mode 100644 index 0000000..0b6bf6f --- /dev/null +++ b/frontend/src/components/landing/sections/community-section.tsx @@ -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 ( +
+ Join the Community + + } + subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts." + > +
+ +
+
+ ); +} diff --git a/frontend/src/components/landing/sections/sandbox-section.tsx b/frontend/src/components/landing/sections/sandbox-section.tsx new file mode 100644 index 0000000..ec34c1e --- /dev/null +++ b/frontend/src/components/landing/sections/sandbox-section.tsx @@ -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 ( +
+ We gave DeerFlow a computer. It can execute code, manage files, and + run long tasks — all in a secure Docker sandbox +

+ } + > +
+ {/* Left: Terminal */} +
+ + {/* Scene 1: Build a Game */} + $ cat requirements.txt + + pygame==2.5.0 + + + + $ pip install -r requirements.txt + + + ✔ Installed pygame + + + + $ write game.py --lines 156 + + + ✔ Written 156 lines + + + + $ python game.py --test + + + ✔ All sprites loaded + + + ✔ Physics engine OK + + + ✔ 60 FPS stable + + + {/* Scene 2: Data Analysis */} + + $ curl -O sales-2024.csv + + + Downloaded 12.4 MB + + +
+ + {/* Right: Description */} +
+
+

+ Open-source +

+

+ + AIO Sandbox + +

+
+ +
+

+ We recommend using{" "} + + All-in-One Sandbox + {" "} + that combines Browser, Shell, File, MCP and VSCode Server in a + single Docker container. +

+
+ + {/* Feature Tags */} +
+ + Isolated + + + Safe + + + Persistent + + + Mountable FS + + + Long-running + +
+
+
+
+ ); +} diff --git a/frontend/src/components/landing/sections/skills-section.tsx b/frontend/src/components/landing/sections/skills-section.tsx new file mode 100644 index 0000000..6efbeb6 --- /dev/null +++ b/frontend/src/components/landing/sections/skills-section.tsx @@ -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 ( +
+ Skills are loaded progressively — only what's needed, when + it's needed. +
+ Extend DeerFlow with your own skill files, or use our built-in + library. +
+ } + > +
+ +
+ + ); +} diff --git a/frontend/src/components/landing/sections/whats-new-section.tsx b/frontend/src/components/landing/sections/whats-new-section.tsx new file mode 100644 index 0000000..bcbafe9 --- /dev/null +++ b/frontend/src/components/landing/sections/whats-new-section.tsx @@ -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 ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/ui/flickering-grid.tsx b/frontend/src/components/ui/flickering-grid.tsx index 73e35ee..15bc3c8 100644 --- a/frontend/src/components/ui/flickering-grid.tsx +++ b/frontend/src/components/ui/flickering-grid.tsx @@ -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 { - 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 = ({ @@ -26,58 +32,58 @@ export const FlickeringGrid: React.FC = ({ maxOpacity = 0.3, ...props }) => { - const canvasRef = useRef(null) - const containerRef = useRef(null) - const [isInView, setIsInView] = useState(false) - const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }) + const canvasRef = useRef(null); + const containerRef = useRef(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 = ({ 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 + let animationFrameId: number; + let gridParams: ReturnType; 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 = ({ 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 (
= ({ }} />
- ) -} + ); +}; diff --git a/frontend/src/components/Galaxy.css b/frontend/src/components/ui/galaxy.css similarity index 100% rename from frontend/src/components/Galaxy.css rename to frontend/src/components/ui/galaxy.css diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/ui/galaxy.jsx similarity index 86% rename from frontend/src/components/Galaxy.jsx rename to frontend/src/components/ui/galaxy.jsx index 53b01cd..549e0e8 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/ui/galaxy.jsx @@ -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; @@ -84,7 +84,7 @@ float Star(vec2 uv, float flare) { vec3 StarLayer(vec2 uv) { vec3 col = vec3(0.0); - vec2 gv = fract(uv) - 0.5; + vec2 gv = fract(uv) - 0.5; vec2 id = floor(uv); for (int y = -1; y <= 1; y++) { @@ -100,7 +100,7 @@ vec3 StarLayer(vec2 uv) { float blu = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 3.0)) + STAR_COLOR_CUTOFF; float grn = min(red, blu) * seed; vec3 base = vec3(red, grn, blu); - + float hue = atan(base.g - base.r, base.b - base.r) / (2.0 * 3.14159) + 0.5; hue = fract(hue + uHueShift / 360.0); float sat = length(base - vec3(dot(base, vec3(0.299, 0.587, 0.114)))) * uSaturation; @@ -115,7 +115,7 @@ vec3 StarLayer(vec2 uv) { float twinkle = trisn(uTime * uSpeed + seed * 6.2831) * 0.5 + 1.0; twinkle = mix(1.0, twinkle, uTwinkleIntensity); star *= twinkle; - + col += star * size * color; } } @@ -128,7 +128,7 @@ void main() { vec2 uv = (vUv * uResolution.xy - focalPx) / uResolution.y; vec2 mouseNorm = uMouse - vec2(0.5); - + if (uAutoCenterRepulsion > 0.0) { vec2 centerUV = vec2(0.0, 0.0); float centerDist = length(uv - centerUV); @@ -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
; diff --git a/frontend/src/components/ui/magic-bento.css b/frontend/src/components/ui/magic-bento.css new file mode 100644 index 0000000..ac6e799 --- /dev/null +++ b/frontend/src/components/ui/magic-bento.css @@ -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; +} diff --git a/frontend/src/components/ui/magic-bento.tsx b/frontend/src/components/ui/magic-bento.tsx new file mode 100644 index 0000000..2aeb690 --- /dev/null +++ b/frontend/src/components/ui/magic-bento.tsx @@ -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: ( +
+
Now the agent can better understand you
+
Coming soon
+
+ ), + 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(null); + const particlesRef = useRef([]); + const timeoutsRef = useRef([]); + const isHoveredRef = useRef(false); + const memoizedParticles = useRef([]); + const particlesInitialized = useRef(false); + const magnetismAnimationRef = useRef(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 ( +
+ {children} +
+ ); +}; + +const GlobalSpotlight: React.FC<{ + gridRef: React.RefObject; + disableAnimations?: boolean; + enabled?: boolean; + spotlightRadius?: number; + glowColor?: string; +}> = ({ + gridRef, + disableAnimations = false, + enabled = true, + spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS, + glowColor = DEFAULT_GLOW_COLOR, +}) => { + const spotlightRef = useRef(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; +}> = ({ children, gridRef }) => ( +
+ {children} +
+); + +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 = ({ + 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(null); + const isMobile = useMobileDetection(); + const shouldDisableAnimations = disableAnimations || isMobile; + + return ( + <> + {enableSpotlight && ( + + )} + + + {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 ( + +
+
{card.label}
+
+
+

{card.title}

+
+ {card.description} +
+
+
+ ); + } + + return ( +
{ + 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); + }} + > +
+
{card.label}
+
+
+

{card.title}

+

+ {card.description} +

+
+
+ ); + })} +
+ + ); +}; + +export default MagicBento; diff --git a/frontend/src/components/ui/number-ticker.tsx b/frontend/src/components/ui/number-ticker.tsx index 0f12b4f..af0d949 100644 --- a/frontend/src/components/ui/number-ticker.tsx +++ b/frontend/src/components/ui/number-ticker.tsx @@ -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(null) - const motionValue = useMotionValue(direction === "down" ? value : startValue) + const ref = useRef(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 ( {startValue} - ) + ); } diff --git a/frontend/src/components/ui/spotlight-card.css b/frontend/src/components/ui/spotlight-card.css new file mode 100644 index 0000000..ee53375 --- /dev/null +++ b/frontend/src/components/ui/spotlight-card.css @@ -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; +} diff --git a/frontend/src/components/ui/spotlight-card.tsx b/frontend/src/components/ui/spotlight-card.tsx new file mode 100644 index 0000000..85b780c --- /dev/null +++ b/frontend/src/components/ui/spotlight-card.tsx @@ -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 = ({ + children, + className = "", + spotlightColor = "rgba(255, 255, 255, 0.25)", +}) => { + const divRef = useRef(null); + + const handleMouseMove: React.MouseEventHandler = (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 ( +
+ {children} +
+ ); +}; + +export default SpotlightCard; diff --git a/frontend/src/components/ui/terminal.tsx b/frontend/src/components/ui/terminal.tsx new file mode 100644 index 0000000..5f6f771 --- /dev/null +++ b/frontend/src/components/ui/terminal.tsx @@ -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(null); + +const useSequence = () => useContext(SequenceContext); + +const ItemIndexContext = createContext(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(null); + const isInView = useInView(elementRef as React.RefObject, { + 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 ( + { + if (!sequence) return; + if (itemIndex === null) return; + sequence.completeItem(itemIndex); + }} + {...props} + > + {children} + + ); +}; + +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(""); + const [started, setStarted] = useState(false); + const elementRef = useRef(null); + const isInView = useInView(elementRef as React.RefObject, { + 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 ( + + {displayedText} + + ); +}; + +interface TerminalProps { + children: React.ReactNode; + className?: string; + sequence?: boolean; + startOnView?: boolean; +} + +export const Terminal = ({ + children, + className, + sequence = true, + startOnView = true, +}: TerminalProps) => { + const containerRef = useRef(null); + const isInView = useInView(containerRef as React.RefObject, { + amount: 0.3, + once: true, + }); + + const [activeIndex, setActiveIndex] = useState(0); + const sequenceHasStarted = sequence ? !startOnView || isInView : false; + + const contextValue = useMemo(() => { + 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) => ( + + {child as React.ReactNode} + + )); + }, [children, sequence]); + + const content = ( +
+
+
+
+
+
+
+
+
+        {wrappedChildren}
+      
+
+ ); + + if (!sequence) return content; + + return ( + + {content} + + ); +}; diff --git a/frontend/src/components/ui/word-rotate.tsx b/frontend/src/components/ui/word-rotate.tsx index 4796ca0..46fa8a0 100644 --- a/frontend/src/components/ui/word-rotate.tsx +++ b/frontend/src/components/ui/word-rotate.tsx @@ -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 (
- +