diff --git a/.github/workflows/deploy-v3.yml b/.github/workflows/deploy-v3.yml index c23b43ec..a2172d34 100644 --- a/.github/workflows/deploy-v3.yml +++ b/.github/workflows/deploy-v3.yml @@ -9,17 +9,19 @@ jobs: deploy-v3: runs-on: ubuntu-latest environment: v3-deployment + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" - name: Install yarn run: npm install -g yarn - name: Restore cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .next/cache diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1f71052e..03896cfc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,19 +11,21 @@ jobs: if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest environment: preview-deployment + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "22" - name: Install yarn run: npm install -g yarn - name: Restore cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .next/cache diff --git a/animata/card/card-spread.tsx b/animata/card/card-spread.tsx index 661f6e3e..7136f129 100644 --- a/animata/card/card-spread.tsx +++ b/animata/card/card-spread.tsx @@ -29,28 +29,9 @@ function RemodelNotes() { } const cards = [ - { - component: Notes, - rotationClass: "", - revealClass: "-rotate-[2deg]", - }, - { - component: ShoppingList, - rotationClass: "group-hover/spread:rotate-[15deg]", - revealClass: "rotate-[3deg] translate-y-2", - }, - - { - component: RemodelNotes, - rotationClass: "group-hover/spread:rotate-[30deg]", - revealClass: "-rotate-[2deg] translate-x-1", - }, - - { - component: Reminders, - rotationClass: "group-hover/spread:rotate-[45deg]", - revealClass: "rotate-[2deg]", - }, + { component: Notes }, + { component: ShoppingList }, + { component: RemodelNotes }, ]; export default function CardSpread() { @@ -58,15 +39,20 @@ export default function CardSpread() { return (
{cards.map((item, index) => { + const mid = (cards.length - 1) / 2; + const offset = (index - mid) * 120; // px to spread when expanded + const rotate = (index - mid) * 5; // small rotation per card + const delay = Math.abs(index - mid) * 140; // stronger stagger delay in ms return (
{ + if (e.key === "Enter" || e.key === " ") { + setExpanded(!isExpanded); + e.preventDefault(); + } + }} + className={cn("relative min-w-52 cursor-pointer", { + "origin-bottom": !isExpanded, + })} + style={{ + transform: isExpanded + ? `translateX(${offset}px) translateY(-22px) rotate(${rotate}deg)` + : `rotate(${rotate / 2}deg)`, + boxShadow: isExpanded + ? "0 22px 56px rgba(2,6,23,0.16)" + : "0 8px 22px rgba(2,6,23,0.07)", + transition: `transform 520ms cubic-bezier(0.22,1,0.36,1) ${delay}ms, box-shadow 520ms ease ${delay}ms`, + }} >
diff --git a/animata/card/state-action-card.stories.tsx b/animata/card/state-action-card.stories.tsx new file mode 100644 index 00000000..5f13e9a5 --- /dev/null +++ b/animata/card/state-action-card.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import StateActionCard from "@/animata/card/state-action-card"; + +const meta = { + title: "Card/State Action Card", + component: StateActionCard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Taskmanager: Story = { + args: { + useCase: "task", + }, +}; + +export const Socialcard: Story = { + args: { + useCase: "social", + }, +}; + +export const Ordercard: Story = { + args: { + useCase: "order", + }, +}; diff --git a/animata/card/state-action-card.tsx b/animata/card/state-action-card.tsx new file mode 100644 index 00000000..b44475dd --- /dev/null +++ b/animata/card/state-action-card.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { + Check, + CheckCircle2, + ClipboardList, + Heart, + Package, + Share2, + Sparkles, + Users, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/lib/utils"; + +type CardUseCase = "task" | "social" | "order"; + +type ActionType = "favorite" | "complete" | "share"; + +interface CardPreset { + title: string; + description: string; + meta: string; + badge: string; + icon: typeof ClipboardList; +} + +interface StateActionCardProps { + readonly useCase?: CardUseCase; + readonly className?: string; +} + +const cardPresets: Record = { + task: { + title: "Finalize Sprint Notes", + description: "Wrap up pending checklist items and post a summary for the team standup.", + meta: "Due in 3 hours", + badge: "Task Manager", + icon: ClipboardList, + }, + social: { + title: "Design Community Spotlight", + description: "A new behind-the-scenes post is trending. Save it or share it with your team.", + meta: "2.4k interactions", + badge: "Social Card", + icon: Users, + }, + order: { + title: "Order #48291", + description: "Wireless Keyboard and Mouse bundle is packed and ready for final dispatch.", + meta: "Ships today", + badge: "Dashboard Order", + icon: Package, + }, +}; + +const confettiPieces = [ + { id: "c1", x: -48, y: -34, rotate: -35, color: "bg-emerald-400" }, + { id: "c2", x: -26, y: -50, rotate: -10, color: "bg-cyan-400" }, + { id: "c3", x: -6, y: -56, rotate: 6, color: "bg-yellow-400" }, + { id: "c4", x: 18, y: -50, rotate: 22, color: "bg-fuchsia-400" }, + { id: "c5", x: 42, y: -34, rotate: 38, color: "bg-orange-400" }, + { id: "c6", x: -36, y: -18, rotate: -24, color: "bg-lime-400" }, + { id: "c7", x: 32, y: -16, rotate: 30, color: "bg-sky-400" }, + { id: "c8", x: 0, y: -30, rotate: 0, color: "bg-violet-400" }, +]; + +export default function StateActionCard({ + useCase = "task", + className, +}: Readonly) { + const preset = cardPresets[useCase]; + const CardIcon = preset.icon; + + const [isFavorite, setIsFavorite] = useState(false); + const [isCompleted, setIsCompleted] = useState(false); + const [isShared, setIsShared] = useState(false); + const [lastAction, setLastAction] = useState(null); + const [showConfetti, setShowConfetti] = useState(false); + + const statuses = useMemo(() => { + return [ + { label: preset.badge, className: "bg-zinc-900 text-white" }, + isCompleted + ? { label: "Completed", className: "bg-emerald-100 text-emerald-700" } + : { label: "In Progress", className: "bg-amber-100 text-amber-700" }, + isFavorite + ? { label: "Favorited", className: "bg-rose-100 text-rose-700" } + : { label: "Not Favorite", className: "bg-zinc-100 text-zinc-600" }, + isShared + ? { label: "Shared", className: "bg-sky-100 text-sky-700" } + : { label: "Private", className: "bg-zinc-100 text-zinc-600" }, + ]; + }, [isCompleted, isFavorite, isShared, preset.badge]); + + const triggerActionFeedback = (action: ActionType) => { + setLastAction(action); + window.setTimeout(() => { + setLastAction((previous) => (previous === action ? null : previous)); + }, 800); + }; + + const onFavorite = () => { + setIsFavorite((previous) => !previous); + triggerActionFeedback("favorite"); + }; + + const onComplete = () => { + const nextValue = !isCompleted; + setIsCompleted(nextValue); + triggerActionFeedback("complete"); + + if (nextValue) { + setShowConfetti(true); + window.setTimeout(() => { + setShowConfetti(false); + }, 1000); + } + }; + + const onShare = () => { + setIsShared((previous) => !previous); + triggerActionFeedback("share"); + }; + + return ( + +
+ +
+
+ + + +

+ Interactive Card +

+
+ + + Live State + +
+ +

{preset.title}

+

{preset.description}

+ +
+ {statuses.map((status) => ( + + {status.label} + + ))} +
+ +
+

{preset.meta}

+ + + {lastAction && ( + + + Action saved + + )} + +
+ +
+ +
+ + + + + +
+ + + {showConfetti && ( + + {confettiPieces.map((piece) => ( + + ))} + + )} + + + ); +} + +interface ActionButtonProps { + readonly icon: typeof Heart; + readonly label: string; + readonly active: boolean; + readonly onClick: () => void; +} + +function ActionButton({ icon: Icon, label, active, onClick }: Readonly) { + return ( + + ); +} diff --git a/animata/carousel/swipe-deck.stories.tsx b/animata/carousel/swipe-deck.stories.tsx new file mode 100644 index 00000000..b8747cbf --- /dev/null +++ b/animata/carousel/swipe-deck.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import SwipeDeck from "@/animata/carousel/swipe-deck"; + +const meta = { + title: "Carousel/Swipe Deck", + component: SwipeDeck, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + className: "w-full min-w-72 storybook-fix", + items: [ + { + id: "onboarding", + badge: "Onboarding", + title: "Welcome flow that keeps people moving", + description: + "Deliver setup tips and key actions in a swipe deck that feels natural on both touch and mouse.", + image: + "https://images.unsplash.com/photo-1517048676732-d65bc937f952?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "featured-post", + badge: "Featured", + title: "Highlight posts with high visual impact", + description: + "Snap cards into focus while users browse stories, updates, and curated editor picks.", + image: + "https://images.unsplash.com/photo-1483058712412-4245e9b90334?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "product-highlight", + badge: "Product", + title: "Showcase product benefits in sequence", + description: + "Each swipe reveals the next value prop with smooth indicator and arrow navigation.", + image: + "https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "recommendations", + badge: "For You", + title: "Personal recommendations, one card at a time", + description: + "Use gesture-friendly cards for picks based on activity, preferences, and intent.", + image: + "https://images.unsplash.com/photo-1551281044-8b4a2f5f6f2d?q=80&w=1400&auto=format&fit=crop", + }, + ], + }, +}; diff --git a/animata/carousel/swipe-deck.tsx b/animata/carousel/swipe-deck.tsx new file mode 100644 index 00000000..16daefc5 --- /dev/null +++ b/animata/carousel/swipe-deck.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { type HTMLAttributes, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +export interface SwipeDeckItem { + readonly id: string; + readonly title: string; + readonly description: string; + readonly image: string; + readonly badge?: string; +} + +interface SwipeDeckProps extends HTMLAttributes { + readonly items?: ReadonlyArray; + readonly showArrows?: boolean; + readonly showIndicators?: boolean; + readonly parallaxStrength?: number; +} + +const defaultItems: SwipeDeckItem[] = [ + { + id: "welcome", + badge: "Onboarding", + title: "Get set up in under 2 minutes", + description: "Guided steps, quick permissions, and smart defaults to get your team moving.", + image: + "https://images.unsplash.com/photo-1517048676732-d65bc937f952?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "featured", + badge: "Featured", + title: "This week's top product highlights", + description: "Explore fresh launches and editor picks picked for speed, utility, and polish.", + image: + "https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "recommendations", + badge: "For You", + title: "Recommendations shaped by your flow", + description: "Personalized cards adapt as you browse, save, and interact with content.", + image: + "https://images.unsplash.com/photo-1551281044-8b4a2f5f6f2d?q=80&w=1400&auto=format&fit=crop", + }, +]; + +export default function SwipeDeck({ + items = defaultItems, + showArrows = true, + showIndicators = true, + parallaxStrength = 36, + className, + ...props +}: Readonly) { + const deckRef = useRef(null); + const cardRefs = useRef>([]); + const rafRef = useRef(null); + + const dragState = useRef({ + pointerId: -1, + startX: 0, + startScrollLeft: 0, + moved: false, + }); + + const [activeIndex, setActiveIndex] = useState(0); + const [isDragging, setIsDragging] = useState(false); + + const hasMultipleCards = items.length > 1; + + const nearestIndex = useCallback((container: HTMLDivElement) => { + const viewportCenter = container.scrollLeft + container.clientWidth / 2; + let closestIndex = 0; + let closestDistance = Number.POSITIVE_INFINITY; + + for (const [index, card] of cardRefs.current.entries()) { + if (!card) { + continue; + } + + const cardCenter = card.offsetLeft + card.offsetWidth / 2; + const distance = Math.abs(cardCenter - viewportCenter); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = index; + } + } + + return closestIndex; + }, []); + + const updateParallax = useCallback(() => { + const container = deckRef.current; + if (!container) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const viewportCenter = containerRect.left + containerRect.width / 2; + + for (const card of cardRefs.current) { + if (!card) { + continue; + } + + const media = card.querySelector("[data-parallax-layer]"); + if (!media) { + continue; + } + + const cardRect = card.getBoundingClientRect(); + const cardCenter = cardRect.left + cardRect.width / 2; + const offset = (cardCenter - viewportCenter) / containerRect.width; + media.style.transform = `translateX(${offset * -parallaxStrength}px) scale(1.08)`; + } + }, [parallaxStrength]); + + const scrollToIndex = useCallback((index: number) => { + const container = deckRef.current; + const card = cardRefs.current[index]; + + if (!container || !card) { + return; + } + + const targetLeft = card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; + container.scrollTo({ left: targetLeft, behavior: "smooth" }); + }, []); + + const onScroll = useCallback(() => { + const container = deckRef.current; + if (!container) { + return; + } + + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + + rafRef.current = requestAnimationFrame(() => { + setActiveIndex(nearestIndex(container)); + updateParallax(); + rafRef.current = null; + }); + }, [nearestIndex, updateParallax]); + + useEffect(() => { + updateParallax(); + + const container = deckRef.current; + if (!container) { + return; + } + + container.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + + return () => { + container.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [onScroll, updateParallax]); + + useEffect(() => { + cardRefs.current = cardRefs.current.slice(0, items.length); + updateParallax(); + }, [items.length, updateParallax]); + + const cards = useMemo( + () => + items.map((item, index) => ( +
{ + cardRefs.current[index] = node; + }} + className="relative w-[82%] shrink-0 snap-center overflow-hidden rounded-2xl border border-black/10 bg-stone-100 shadow-[0_12px_30px_-16px_rgba(0,0,0,0.45)] sm:w-[72%] lg:w-[64%]" + > +
+
+
+ {item.badge && ( + + {item.badge} + + )} +
+ +
+

+ {item.title} +

+

+ {item.description} +

+
+
+ )), + [items], + ); + + return ( +
+
+
{ + if (event.pointerType !== "mouse") { + return; + } + + const container = deckRef.current; + if (!container) { + return; + } + + dragState.current.pointerId = event.pointerId; + dragState.current.startX = event.clientX; + dragState.current.startScrollLeft = container.scrollLeft; + dragState.current.moved = false; + + container.setPointerCapture(event.pointerId); + setIsDragging(true); + }} + onPointerMove={(event) => { + const container = deckRef.current; + if (!container) { + return; + } + + if (!isDragging || dragState.current.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - dragState.current.startX; + if (Math.abs(deltaX) > 3) { + dragState.current.moved = true; + } + + container.scrollLeft = dragState.current.startScrollLeft - deltaX; + }} + onPointerUp={(event) => { + const container = deckRef.current; + if (!container || dragState.current.pointerId !== event.pointerId) { + return; + } + + container.releasePointerCapture(event.pointerId); + setIsDragging(false); + scrollToIndex(nearestIndex(container)); + }} + onPointerCancel={(event) => { + const container = deckRef.current; + if (!container || dragState.current.pointerId !== event.pointerId) { + return; + } + + container.releasePointerCapture(event.pointerId); + setIsDragging(false); + scrollToIndex(nearestIndex(container)); + }} + > + {cards} +
+ + {showArrows && hasMultipleCards && ( + <> + + + + + )} +
+ + {showIndicators && hasMultipleCards && ( +
+ {items.map((item, index) => ( +
+ )} +
+ ); +} diff --git a/animata/feature-card/metric.stories.tsx b/animata/feature-card/metric.stories.tsx new file mode 100644 index 00000000..214ffe7e --- /dev/null +++ b/animata/feature-card/metric.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Gauge } from "lucide-react"; + +import { FeatureCard } from "@/animata/section/animated-feature-grid"; +import type { FeatureGridItem } from "@/animata/section/animated-feature-grid"; + +const item: FeatureGridItem = { + icon: , + title: "Developer preference for GitHub Copilot", + description: "Stack Overflow 2023 Survey", + metric: "55%", + metricCaption: "Stack Overflow 2023 Survey", + tone: "violet", +}; + +const meta = { + title: "Component/Feature Card - Metric", + component: FeatureCard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Metric: Story = { + render: () => ( +
+
+ +
+
+ ), +}; diff --git a/animata/hero/floating-product-hero-screen.tsx b/animata/hero/floating-product-hero-screen.tsx new file mode 100644 index 00000000..7cdb0c0f --- /dev/null +++ b/animata/hero/floating-product-hero-screen.tsx @@ -0,0 +1,430 @@ +"use client"; + +import type React from "react"; +import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"; +import { cn } from "@/lib/utils"; + +export interface FloatingProductHeroCard { + title: string; + subtitle: string; + accent?: string; + badge?: string; + height?: number; +} + +export interface FloatingProductHeroProps { + title?: string; + subtitle?: string; + primaryCtaText?: string; + secondaryCtaText?: string; + onPrimaryCta?: () => void; + onSecondaryCta?: () => void; + theme?: "dark" | "light"; + animationEnabled?: boolean; + className?: string; + minHeight?: string; + navLinks?: Array<{ label: string; href?: string }>; + logos?: string[]; + cards?: FloatingProductHeroCard[]; + showAllCards?: boolean; +} + +const defaultNavLinks = [ + { label: "Features" }, + { label: "Solutions" }, + { label: "Role" }, + { label: "Teams" }, + { label: "Pricing" }, + { label: "Blog" }, + { label: "Careers" }, +]; + +const defaultLogos = ["Google", "Anthropic", "coinbase", "Hg", "oscar", "ARK"]; + +const defaultCards: FloatingProductHeroCard[] = [ + { + title: "Newsletter automation", + subtitle: "Generate layouts, assets, and launch-ready flows from a single prompt.", + accent: "from-rose-300/50 via-orange-300/30 to-amber-200/10", + badge: "New", + height: 214, + }, + { + title: "Social graphics", + subtitle: "Create beautiful campaign graphics without needing a full design stack.", + accent: "from-sky-300/50 via-cyan-300/20 to-blue-200/10", + badge: "AI", + height: 226, + }, + { + title: "Speaking coach", + subtitle: "Practice delivery and get live feedback on clarity, pacing, and confidence.", + accent: "from-violet-300/50 via-fuchsia-300/20 to-indigo-200/10", + badge: "Pro", + height: 206, + }, + { + title: "Workflow builder", + subtitle: "Sketch a product, automate the boring parts, then ship with confidence.", + accent: "from-emerald-300/40 via-teal-300/20 to-cyan-200/10", + badge: "Beta", + height: 232, + }, +]; + +function BrandMark() { + return ( +
+
+
+ + + + +
+
+ replit +
+ ); +} + +function NavLinkRow({ links }: Readonly<{ links: Array<{ label: string; href?: string }> }>) { + return ( + + ); +} + +function TopActionButton({ + children, + variant = "ghost", + onClick, + reduceMotion, +}: Readonly<{ + children: React.ReactNode; + variant?: "ghost" | "solid"; + onClick?: () => void; + reduceMotion: boolean; +}>) { + const isSolid = variant === "solid"; + + return ( + + {children} + + ); +} + +function HeroCtaButton({ + children, + variant = "primary", + onClick, + reduceMotion, +}: Readonly<{ + children: React.ReactNode; + variant?: "primary" | "secondary"; + onClick?: () => void; + reduceMotion: boolean; +}>) { + const primary = variant === "primary"; + + return ( + + {children} + + ); +} + +function PreviewCard({ + card, + index, + reduceMotion, + animationEnabled, +}: Readonly<{ + card: FloatingProductHeroCard; + index: number; + reduceMotion: boolean; + animationEnabled: boolean; +}>) { + const isLeft = index < 2; + const isTop = index % 2 === 0; + + return ( + +
+
+
+ {card.badge ?? "Preview"} +
+
+ {String(index + 1).padStart(2, "0")} +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +

{card.subtitle}

+
+ + ); +} + +function PromptPanel({ + reduceMotion, + animationEnabled, +}: Readonly<{ + reduceMotion: boolean; + animationEnabled: boolean; +}>) { + const lineVariants = animationEnabled && !reduceMotion ? { opacity: 1, y: 0 } : undefined; + + return ( + +
+ Get suggestions + + Write a prompt + +
+ +
+ + Make me an automation + + + for newsletter publishers + + + that helps create beautiful graphics for + + + social media without design skills + +
+ +
+ Include A/B testing capabilities +
+ +
+ + Start building with AI + +
+
+ ); +} + +function LogoStrip({ logos }: Readonly<{ logos: string[] }>) { + return ( +
+ {logos.map((logo) => ( + + {logo} + + ))} +
+ ); +} + +function TopBar({ + navLinks, + reduceMotion, + onPrimaryCta, +}: Readonly<{ + navLinks: Array<{ label: string; href?: string }>; + reduceMotion: boolean; + onPrimaryCta?: () => void; +}>) { + return ( +
+
+ + +
+ +
+ Contact sales + Log in + + Sign up + +
+
+ ); +} + +export function FloatingProductHero({ + title = "Turn your ideas into apps", + subtitle = "What will you create? The possibilities are endless.", + primaryCtaText = "Start building with AI", + secondaryCtaText = "Write a prompt", + onPrimaryCta, + onSecondaryCta, + theme = "dark", + animationEnabled = true, + className, + minHeight = "100vh", + navLinks = defaultNavLinks, + logos = defaultLogos, + cards = defaultCards, + showAllCards = true, +}: Readonly) { + const reduceMotion = useReducedMotion(); + const shouldAnimate = animationEnabled && !reduceMotion; + const mouseX = useMotionValue(0); + const mouseY = useMotionValue(0); + const springX = useSpring(mouseX, { stiffness: 90, damping: 18, mass: 0.15 }); + const springY = useSpring(mouseY, { stiffness: 90, damping: 18, mass: 0.15 }); + + const handlePointerMove = (event: React.PointerEvent) => { + if (!shouldAnimate) return; + const bounds = event.currentTarget.getBoundingClientRect(); + const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 20; + const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 12; + mouseX.set(x); + mouseY.set(y); + }; + + const resetPointer = () => { + mouseX.set(0); + mouseY.set(0); + }; + + return ( +
+
+
+ +
+ + +
+ +

+ {title} +

+

+ {subtitle} +

+
+ +
+ + + + {showAllCards ? ( +
+ {cards.map((card, index) => ( + + ))} +
+ ) : null} +
+
+ + + + + + {primaryCtaText} + + + {secondaryCtaText} + + +
+
+
+ ); +} + +export default FloatingProductHero; diff --git a/animata/hero/floating-product-hero.stories.tsx b/animata/hero/floating-product-hero.stories.tsx new file mode 100644 index 00000000..3e9ae096 --- /dev/null +++ b/animata/hero/floating-product-hero.stories.tsx @@ -0,0 +1,218 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FloatingProductHero } from "./floating-product-hero"; + +const meta = { + title: "Hero/Floating Product Hero", + component: FloatingProductHero, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "A production-ready floating product hero section with glassmorphic cards, smooth animations, and parallax effects. Inspired by premium SaaS/AI landing pages (Stripe, Linear, Vercel, Framer).", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + title: { + control: "text", + description: "Main headline text", + }, + subtitle: { + control: "text", + description: "Supporting paragraph text", + }, + primaryCtaText: { + control: "text", + description: "Primary call-to-action button text", + }, + secondaryCtaText: { + control: "text", + description: "Secondary call-to-action button text", + }, + showAllCards: { + control: "boolean", + description: "Show/hide floating background cards", + }, + theme: { + control: "select", + options: ["dark", "light"], + description: "Color theme", + }, + animationEnabled: { + control: "boolean", + description: "Enable/disable animations", + }, + minHeight: { + control: "text", + description: "Minimum height of the hero section", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default hero section with all features enabled + */ +export const Default: Story = { + args: { + title: "Build the Future Faster", + subtitle: + "Experience cutting-edge design with smooth animations and premium aesthetics. Perfect for modern SaaS and AI landing pages.", + primaryCtaText: "Get Started Free", + secondaryCtaText: "View Demo", + showAllCards: true, + theme: "dark", + animationEnabled: true, + minHeight: "100vh", + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; + +/** + * With animations disabled (respects prefers-reduced-motion) + */ +export const NoAnimations: Story = { + args: { + ...Default.args, + animationEnabled: false, + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; + +/** + * Without floating cards + */ +export const WithoutCards: Story = { + args: { + ...Default.args, + showAllCards: false, + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; + +/** + * Custom content for tech product + */ +export const TechProduct: Story = { + args: { + title: "Ship Code, Not Excuses", + subtitle: + "Deploy intelligent applications with AI-powered insights and real-time collaboration. Built for modern development teams.", + primaryCtaText: "Start Building", + secondaryCtaText: "Read Docs", + showAllCards: true, + theme: "dark", + animationEnabled: true, + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; + +/** + * Custom content for AI product + */ +export const AIProduct: Story = { + args: { + title: "AI That Understands Your Workflow", + subtitle: + "Harness the power of advanced machine learning to automate complex tasks and unlock insights. Seamlessly integrated with your existing tools.", + primaryCtaText: "Try for Free", + secondaryCtaText: "Request Demo", + showAllCards: true, + theme: "dark", + animationEnabled: true, + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; + +/** + * Shorter viewport + */ +export const ShortHero: Story = { + args: { + ...Default.args, + minHeight: "70vh", + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; + +/** + * Mobile responsive view + */ +export const Mobile: Story = { + args: { + ...Default.args, + }, + parameters: { + viewport: { + defaultViewport: "mobile1", + }, + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; + +/** + * Tablet responsive view + */ +export const Tablet: Story = { + args: { + ...Default.args, + }, + parameters: { + viewport: { + defaultViewport: "tablet", + }, + }, + render: (args) => ( + alert("Primary CTA clicked!")} + onSecondaryCta={() => alert("Secondary CTA clicked!")} + /> + ), +}; diff --git a/animata/hero/floating-product-hero.tsx b/animata/hero/floating-product-hero.tsx new file mode 100644 index 00000000..c9991870 --- /dev/null +++ b/animata/hero/floating-product-hero.tsx @@ -0,0 +1,423 @@ +"use client"; + +import type React from "react"; +import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"; +import { cn } from "@/lib/utils"; + +export interface FloatingProductHeroCard { + title: string; + subtitle: string; + accent?: string; + badge?: string; + height?: number; +} + +export interface FloatingProductHeroProps { + title?: string; + subtitle?: string; + primaryCtaText?: string; + secondaryCtaText?: string; + onPrimaryCta?: () => void; + onSecondaryCta?: () => void; + theme?: "dark" | "light"; + animationEnabled?: boolean; + className?: string; + minHeight?: string; + navLinks?: Array<{ label: string; href?: string }>; + logos?: string[]; + cards?: FloatingProductHeroCard[]; + showAllCards?: boolean; +} + +const defaultNavLinks = [ + { label: "Features" }, + { label: "Solutions" }, + { label: "Role" }, + { label: "Teams" }, + { label: "Pricing" }, + { label: "Blog" }, + { label: "Careers" }, +]; + +const defaultLogos = ["Google", "Anthropic", "coinbase", "Hg", "oscar", "ARK"]; + +const defaultCards: FloatingProductHeroCard[] = [ + { + title: "Newsletter automation", + subtitle: "Generate layouts, assets, and launch-ready flows from a single prompt.", + accent: "from-orange-300 via-orange-200 to-orange-100", + badge: "New", + height: 214, + }, + { + title: "Social graphics", + subtitle: "Create beautiful campaign graphics without needing a full design stack.", + accent: "from-sky-200 via-sky-100 to-blue-100", + badge: "AI", + height: 226, + }, + { + title: "Speaking coach", + subtitle: "Practice delivery and get live feedback on clarity, pacing, and confidence.", + accent: "from-violet-200 via-purple-100 to-indigo-100", + badge: "Pro", + height: 206, + }, + { + title: "Workflow builder", + subtitle: "Sketch a product, automate the boring parts, then ship with confidence.", + accent: "from-emerald-200 via-teal-100 to-cyan-100", + badge: "Beta", + height: 232, + }, +]; + +function BrandMark() { + return ( +
+
+
+ + + + +
+
+ replit +
+ ); +} + +function NavLinkRow({ links }: Readonly<{ links: Array<{ label: string; href?: string }> }>) { + return ( + + ); +} + +function TopActionButton({ + children, + variant = "ghost", + onClick, + reduceMotion, +}: Readonly<{ + children: React.ReactNode; + variant?: "ghost" | "solid"; + onClick?: () => void; + reduceMotion: boolean; +}>) { + const isSolid = variant === "solid"; + + return ( + + {children} + + ); +} + +function HeroCtaButton({ + children, + variant = "primary", + onClick, + reduceMotion, +}: Readonly<{ + children: React.ReactNode; + variant?: "primary" | "secondary"; + onClick?: () => void; + reduceMotion: boolean; +}>) { + const primary = variant === "primary"; + + return ( + + {children} + + ); +} + +function PreviewCard({ + card, + index, + reduceMotion, + animationEnabled, +}: Readonly<{ + card: FloatingProductHeroCard; + index: number; + reduceMotion: boolean; + animationEnabled: boolean; +}>) { + const isLeft = index < 2; + const isTop = index % 2 === 0; + + return ( + +
+
+
+ {card.badge ?? "Preview"} +
+
+ {String(index + 1).padStart(2, "0")} +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +

{card.subtitle}

+
+ + ); +} + +function PromptPanel({ + reduceMotion, + animationEnabled, +}: Readonly<{ + reduceMotion: boolean; + animationEnabled: boolean; +}>) { + return ( + +
+ Get suggestions + + Write a prompt + +
+ +
+
+ Make me an automation +
+
+ for newsletter publishers +
+
+ that helps create beautiful graphics for +
+
social media without design skills
+
+ +
+ Include A/B testing capabilities +
+ +
+ + Start building with AI + +
+
+ ); +} + +function LogoStrip({ logos }: Readonly<{ logos: string[] }>) { + return ( +
+ {logos.map((logo) => ( + + {logo} + + ))} +
+ ); +} + +function TopBar({ + navLinks, + reduceMotion, + onPrimaryCta, +}: Readonly<{ + navLinks: Array<{ label: string; href?: string }>; + reduceMotion: boolean; + onPrimaryCta?: () => void; +}>) { + return ( +
+
+ + +
+ +
+ Contact sales + Log in + + Sign up + +
+
+ ); +} + +export function FloatingProductHero({ + title = "Turn your ideas into apps", + subtitle = "What will you create? The possibilities are endless.", + primaryCtaText = "Start building with AI", + secondaryCtaText = "Write a prompt", + onPrimaryCta, + onSecondaryCta, + theme = "dark", + animationEnabled = true, + className, + minHeight = "100vh", + navLinks = defaultNavLinks, + logos = defaultLogos, + cards = defaultCards, + showAllCards = true, +}: Readonly) { + const reduceMotion = useReducedMotion(); + const shouldAnimate = animationEnabled && !reduceMotion; + const mouseX = useMotionValue(0); + const mouseY = useMotionValue(0); + const springX = useSpring(mouseX, { stiffness: 90, damping: 18, mass: 0.15 }); + const springY = useSpring(mouseY, { stiffness: 90, damping: 18, mass: 0.15 }); + + const handlePointerMove = (event: React.PointerEvent) => { + if (!shouldAnimate) return; + const bounds = event.currentTarget.getBoundingClientRect(); + const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 20; + const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 12; + mouseX.set(x); + mouseY.set(y); + }; + + const resetPointer = () => { + mouseX.set(0); + mouseY.set(0); + }; + + return ( +
+
+
+ +
+ + +
+ +

+ {title} +

+

+ {subtitle} +

+
+ +
+ + + + {showAllCards ? ( +
+ {cards.map((card, index) => ( + + ))} +
+ ) : null} +
+
+ + + + + + {primaryCtaText} + + + {secondaryCtaText} + + +
+
+
+ ); +} + +export default FloatingProductHero; diff --git a/animata/hero/product-features.tsx b/animata/hero/product-features.tsx index 39250a70..2d748491 100644 --- a/animata/hero/product-features.tsx +++ b/animata/hero/product-features.tsx @@ -89,10 +89,10 @@ export default function ProductFeatures() { className="flex max-w-md flex-col items-center gap-2 text-center" >

Pots of Joy: Ceramic Chic!

- +
Quirky ceramics for happy spaces. From sleek vases to funky mugs, we've got your shelves covered. - +
console.log("Profile"), + }, + { + label: "Settings", + description: "Adjust preferences and permissions", + onSelect: () => console.log("Settings"), + }, + { + label: "Documentation", + description: "Open the docs in a new tab", + href: "https://nextjs.org/docs", + }, + { label: "Danger zone", description: "Disabled action example", disabled: true }, +]; + +const meta = { + title: "Overlay/Dropdown Menu", + component: DropdownMenu, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + triggerMode: { + control: { type: "select" }, + options: ["click", "hover"], + }, + placement: { + control: { type: "select" }, + options: ["bottom-start", "bottom-end", "top-start", "top-end"], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: { + items, + label: "Open menu", + triggerMode: "click", + placement: "bottom-start", + }, +}; + +export const HoverTrigger: Story = { + args: { + items, + label: "Hover menu", + triggerMode: "hover", + placement: "bottom-start", + }, +}; diff --git a/animata/overlay/dropdown-menu.tsx b/animata/overlay/dropdown-menu.tsx new file mode 100644 index 00000000..c2bc7a76 --- /dev/null +++ b/animata/overlay/dropdown-menu.tsx @@ -0,0 +1,583 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import Link from "next/link"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +export type DropdownMenuItem = { + label: string; + description?: string; + href?: string; + onSelect?: () => void; + disabled?: boolean; +}; + +type DropdownMenuProps = { + items?: DropdownMenuItem[]; + label?: string; + ariaLabel?: string; + triggerMode?: "click" | "hover"; + placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"; + defaultOpen?: boolean; + className?: string; + triggerClassName?: string; + menuClassName?: string; + itemClassName?: string; +}; + +const defaultItems: DropdownMenuItem[] = [ + { + label: "Profile", + description: "View your account details", + onSelect: () => {}, + }, + { + label: "Settings", + description: "Adjust preferences and permissions", + onSelect: () => {}, + }, + { + label: "Documentation", + description: "Open the docs in a new tab", + href: "https://nextjs.org/docs", + }, + { label: "Danger zone", description: "Disabled action example", disabled: true }, +]; + +const getNextEnabledIndex = (items: DropdownMenuItem[], startIndex: number, direction: 1 | -1) => { + if (!items.length) { + return -1; + } + + let index = startIndex; + + for (const _ of items) { + index = (index + direction + items.length) % items.length; + + if (!items[index]?.disabled) { + return index; + } + } + + return -1; +}; + +export default function DropdownMenu({ + items, + label = "Menu", + ariaLabel = "Dropdown menu", + triggerMode = "click", + placement = "bottom-start", + defaultOpen = false, + className, + triggerClassName, + menuClassName, + itemClassName, +}: Readonly) { + const menuItems = items ?? defaultItems; + const [open, setOpen] = useState(defaultOpen); + const [activeIndex, setActiveIndex] = useState(-1); + const wrapperRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const menuId = useId(); + const triggerId = `${menuId}-trigger`; + const menuAboveTrigger = placement.startsWith("top"); + const menuSpacingClass = menuAboveTrigger ? "mb-2" : "mt-2"; + + const closeMenu = useCallback(() => { + setOpen(false); + setActiveIndex(-1); + }, []); + + const openMenu = useCallback( + (nextIndex?: number) => { + setOpen(true); + setActiveIndex((currentIndex) => { + if (typeof nextIndex === "number") { + return nextIndex; + } + + if ( + currentIndex >= 0 && + currentIndex < menuItems.length && + !menuItems[currentIndex]?.disabled + ) { + return currentIndex; + } + + return getNextEnabledIndex(menuItems, -1, 1); + }); + }, + [menuItems], + ); + + const selectItem = useCallback( + (item: DropdownMenuItem) => { + if (item.disabled) { + return; + } + + item.onSelect?.(); + closeMenu(); + triggerRef.current?.focus(); + }, + [closeMenu], + ); + + const handleTriggerClick = useCallback(() => { + if (triggerMode === "hover") { + return; + } + + if (open) { + closeMenu(); + return; + } + + openMenu(); + }, [closeMenu, open, openMenu, triggerMode]); + + const handleTriggerKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeMenu(); + return; + } + + if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openMenu(getNextEnabledIndex(menuItems, -1, 1)); + requestAnimationFrame(() => { + menuRef.current?.focus(); + }); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + openMenu(getNextEnabledIndex(menuItems, menuItems.length, -1)); + requestAnimationFrame(() => { + menuRef.current?.focus(); + }); + } + }, + [closeMenu, menuItems, openMenu], + ); + + const handleMenuKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeMenu(); + triggerRef.current?.focus(); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((currentIndex) => getNextEnabledIndex(menuItems, currentIndex, 1)); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((currentIndex) => getNextEnabledIndex(menuItems, currentIndex, -1)); + return; + } + + if (event.key === "Home") { + event.preventDefault(); + setActiveIndex(getNextEnabledIndex(menuItems, -1, 1)); + return; + } + + if (event.key === "End") { + event.preventDefault(); + setActiveIndex(getNextEnabledIndex(menuItems, menuItems.length, -1)); + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + + if (activeIndex < 0) { + return; + } + + const item = menuItems[activeIndex]; + + if (item) { + selectItem(item); + } + } + + if (event.key === "Tab") { + closeMenu(); + } + }, + [activeIndex, closeMenu, menuItems, selectItem], + ); + + useEffect(() => { + if (!open) { + return; + } + + if (activeIndex === -1) { + setActiveIndex(getNextEnabledIndex(menuItems, -1, 1)); + } + + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + + if (!wrapperRef.current?.contains(target)) { + closeMenu(); + } + }; + + document.addEventListener("pointerdown", onPointerDown); + + return () => { + document.removeEventListener("pointerdown", onPointerDown); + }; + }, [activeIndex, closeMenu, menuItems, open]); + + + useEffect(() => { + if (!open) { + return; + } + + const frame = requestAnimationFrame(() => { + menuRef.current?.focus(); + }); + + return () => { + cancelAnimationFrame(frame); + }; + }, [open]); + + useEffect(() => { + if (activeIndex >= menuItems.length) { + setActiveIndex(getNextEnabledIndex(menuItems, -1, 1)); + } + }, [activeIndex, menuItems]); + + return ( +
{ + if (triggerMode === "hover") { + openMenu(); + } + }} + onPointerLeave={() => { + if (triggerMode === "hover") { + closeMenu(); + } + }} + > + {menuAboveTrigger ? ( + <> + + {open && ( + + + + )} + + + + ) : ( + <> + + + {open && ( + + + + )} + + + )} +
+ ); +} diff --git a/animata/section/animated-feature-grid.stories.tsx b/animata/section/animated-feature-grid.stories.tsx new file mode 100644 index 00000000..dda60ac7 --- /dev/null +++ b/animata/section/animated-feature-grid.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Gauge } from "lucide-react"; + +import AnimatedFeatureGrid, { type FeatureGridItem } from "@/animata/section/animated-feature-grid"; + +const demoItems: FeatureGridItem[] = [ + { + icon: , + title: "55%", + description: "Developer preference for GitHub Copilot", + metric: "55%", + metricCaption: "Stack Overflow 2023 Survey", + tone: "violet", + }, +]; + +const meta = { + title: "Section/Animated Feature Grid", + component: AnimatedFeatureGrid, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "A premium interactive feature grid with cursor-follow spotlight glow, hover lift, and animated border reveal.", + }, + }, + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + eyebrow: "Interactive feature showcase", + title: "Premium feature cards with cursor spotlight and animated borders", + description: + "This demo demonstrates the reusable feature-grid component with independent card motion, hover lift, smooth spotlight tracking, and a touch-friendly fallback.", + items: demoItems, + gridClassName: "mx-auto max-w-[280px] justify-items-center gap-6", + }, +}; diff --git a/animata/section/animated-feature-grid.tsx b/animata/section/animated-feature-grid.tsx new file mode 100644 index 00000000..155cdd52 --- /dev/null +++ b/animata/section/animated-feature-grid.tsx @@ -0,0 +1,9 @@ +export { + AnimatedFeatureGrid, + type AnimatedFeatureGridProps, + default, + FeatureCard, + type FeatureCardProps, + type FeatureGridItem, + type FeatureTone, +} from "@/components/animated-feature-grid"; diff --git a/animata/section/pricing-comparison.stories.tsx b/animata/section/pricing-comparison.stories.tsx new file mode 100644 index 00000000..c475040f --- /dev/null +++ b/animata/section/pricing-comparison.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import PricingComparison from "./pricing-comparison"; + +const meta = { + title: "Section/Pricing Comparison", + component: PricingComparison, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const basePlans = [ + { + id: "starter", + name: "Starter", + description: "For side projects and indie launches.", + monthlyPrice: 19, + yearlyPrice: 190, + ctaLabel: "Start free", + }, + { + id: "growth", + name: "Growth", + description: "For scaling teams shipping quickly.", + monthlyPrice: 49, + yearlyPrice: 470, + badge: "Most popular", + highlighted: true, + ctaLabel: "Choose growth", + }, + { + id: "scale", + name: "Scale", + description: "For products with advanced ops needs.", + monthlyPrice: 99, + yearlyPrice: 950, + ctaLabel: "Talk to sales", + }, +]; + +const baseFeatures = [ + { + feature: "Projects", + values: { + starter: "3", + growth: "20", + scale: "Unlimited", + }, + }, + { + feature: "Team members", + values: { + starter: "2", + growth: "15", + scale: "Unlimited", + }, + }, + { + feature: "Advanced analytics", + values: { + starter: false, + growth: true, + scale: true, + }, + }, + { + feature: "Priority support", + description: "Guaranteed response times from support engineers.", + values: { + starter: false, + growth: "Business hours", + scale: "24/7", + }, + }, + { + feature: "SLA", + values: { + starter: false, + growth: "99.9%", + scale: "99.99%", + }, + }, +]; + +export const Primary: Story = { + args: { + plans: basePlans, + features: baseFeatures, + }, +}; + +export const FourPlans: Story = { + args: { + plans: [ + ...basePlans, + { + id: "enterprise", + name: "Enterprise", + description: "For organizations with strict security and compliance.", + monthlyPrice: "Custom", + yearlyPrice: "Custom", + ctaLabel: "Contact enterprise sales", + }, + ], + features: [ + ...baseFeatures, + { + feature: "Single sign-on", + values: { + starter: false, + growth: false, + scale: true, + enterprise: true, + }, + }, + { + feature: "Account manager", + values: { + starter: false, + growth: false, + scale: false, + enterprise: true, + }, + }, + ], + defaultCycle: "yearly", + defaultPlanId: "growth", + }, +}; diff --git a/animata/section/pricing-comparison.tsx b/animata/section/pricing-comparison.tsx new file mode 100644 index 00000000..53d5c6b8 --- /dev/null +++ b/animata/section/pricing-comparison.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { Check, Minus } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/lib/utils"; + +type BillingCycle = "monthly" | "yearly"; +type FeatureValue = boolean | string; + +export interface PricingComparisonPlan { + id: string; + name: string; + description?: string; + monthlyPrice: number | string; + yearlyPrice: number | string; + badge?: string; + highlighted?: boolean; + ctaLabel?: string; + onCtaClick?: (planId: string) => void; +} + +export interface PricingComparisonFeature { + feature: string; + description?: string; + values: Record; +} + +interface PricingComparisonProps { + plans: PricingComparisonPlan[]; + features: PricingComparisonFeature[]; + currency?: string; + title?: string; + subtitle?: string; + defaultCycle?: BillingCycle; + defaultPlanId?: string; + className?: string; + onPlanChange?: (planId: string) => void; +} + +const formatPrice = (value: number | string, currency: string): string => { + if (typeof value === "string") { + return value; + } + + return `${currency}${value}`; +}; + +const getFirstPlanId = (plans: PricingComparisonPlan[]): string => { + return plans[0]?.id ?? ""; +}; + +const getStartingPlanId = (plans: PricingComparisonPlan[], defaultPlanId?: string): string => { + if (defaultPlanId && plans.some((plan) => plan.id === defaultPlanId)) { + return defaultPlanId; + } + + const highlightedPlan = plans.find((plan) => plan.highlighted); + return highlightedPlan?.id ?? getFirstPlanId(plans); +}; + +const getFeatureCellValue = (value: FeatureValue) => { + if (typeof value === "boolean") { + return value ? ( +