From 0e8e5edebd6e4c7ce15d4d9e9262ae7c4cbe57b2 Mon Sep 17 00:00:00 2001 From: leazi99 Date: Fri, 24 Apr 2026 14:14:38 +0545 Subject: [PATCH 1/7] feat(carousel): add swipe deck with snap, indicators, and parallax --- animata/carousel/swipe-deck.stories.tsx | 59 +++++ animata/carousel/swipe-deck.tsx | 328 ++++++++++++++++++++++++ content/docs/carousel/swipe-deck.mdx | 39 +++ 3 files changed, 426 insertions(+) create mode 100644 animata/carousel/swipe-deck.stories.tsx create mode 100644 animata/carousel/swipe-deck.tsx create mode 100644 content/docs/carousel/swipe-deck.mdx 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..92bcc479 --- /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/content/docs/carousel/swipe-deck.mdx b/content/docs/carousel/swipe-deck.mdx new file mode 100644 index 00000000..11a3da4f --- /dev/null +++ b/content/docs/carousel/swipe-deck.mdx @@ -0,0 +1,39 @@ +--- +title: Swipe Deck +description: A horizontal swipeable card deck with snap scrolling, indicators, arrows, and parallax motion. +author: copilot +labels: ["requires interaction", "touch", "drag", "swipe"] +published: true +--- + + + +## Installation + + +Install dependencies + +```bash +npm install lucide-react +``` + +Run the following command + +It will create a new file called `swipe-deck.tsx` inside the `components/animata/carousel` directory. + +```bash +mkdir -p components/animata/carousel && touch components/animata/carousel/swipe-deck.tsx +``` + +Paste the code + +Open the newly created file and paste the following code: + +```tsx file=/animata/carousel/swipe-deck.tsx +``` + + + +## Credits + +Built by [GitHub Copilot](https://github.com/features/copilot) From d8821da7520934f4f5da88c789ad0b493b4aa4b8 Mon Sep 17 00:00:00 2001 From: leazi99 Date: Fri, 24 Apr 2026 14:43:53 +0545 Subject: [PATCH 2/7] fix(carousel): relax touch action for swipe deck --- animata/carousel/swipe-deck.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/animata/carousel/swipe-deck.tsx b/animata/carousel/swipe-deck.tsx index 92bcc479..16daefc5 100644 --- a/animata/carousel/swipe-deck.tsx +++ b/animata/carousel/swipe-deck.tsx @@ -217,12 +217,12 @@ export default function SwipeDeck({ ref={deckRef} className={cn( "flex snap-x snap-mandatory gap-4 overflow-x-auto px-[9%] pb-2 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", - "touch-pan-y cursor-grab active:cursor-grabbing", + "touch-pan-x cursor-grab active:cursor-grabbing", { "select-none": isDragging, }, )} - style={{ touchAction: "pan-y pinch-zoom" }} + style={{ touchAction: "auto" }} onPointerDown={(event) => { if (event.pointerType !== "mouse") { return; From 8def0ee82a3a0193b0257d8ba9755d309dd3d22c Mon Sep 17 00:00:00 2001 From: leazi99 Date: Fri, 24 Apr 2026 16:09:59 +0545 Subject: [PATCH 3/7] feat(card): add state action card with contextual actions --- animata/card/state-action-card.stories.tsx | 33 +++ animata/card/state-action-card.tsx | 271 +++++++++++++++++++++ content/docs/card/state-action-card.mdx | 45 ++++ 3 files changed, 349 insertions(+) create mode 100644 animata/card/state-action-card.stories.tsx create mode 100644 animata/card/state-action-card.tsx create mode 100644 content/docs/card/state-action-card.mdx diff --git a/animata/card/state-action-card.stories.tsx b/animata/card/state-action-card.stories.tsx new file mode 100644 index 00000000..d1787e65 --- /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/content/docs/card/state-action-card.mdx b/content/docs/card/state-action-card.mdx new file mode 100644 index 00000000..a7a1e9c1 --- /dev/null +++ b/content/docs/card/state-action-card.mdx @@ -0,0 +1,45 @@ +--- +title: State Action Card +description: Cards with state-based action buttons, status badges, hover-reveal actions, and success feedback animations. +labels: ["requires interaction", "hover", "state", "actions"] +author: ujjwalbasnet +published: false +--- + + + +## Installation + + +Install dependencies + +```bash +npm install motion lucide-react +``` + +Run the following command + +It will create a new file `state-action-card.tsx` inside the `components/animata/card` directory. + +```bash +mkdir -p components/animata/card && touch components/animata/card/state-action-card.tsx +``` + +Paste the code + +Open the newly created file and paste the following code: + +```tsx file=/animata/card/state-action-card.tsx +``` + + + +## Use Cases + +- task managers +- social cards +- order cards in a dashboard + +## Credits + +Built by [Ujjwal Basnet](https://github.com/ujjwalbasnet) From 4015e78dc7953ab6f3e1907b4b26f53a1fbbe8af Mon Sep 17 00:00:00 2001 From: leazi99 Date: Fri, 24 Apr 2026 16:36:50 +0545 Subject: [PATCH 4/7] chore(ci): upgrade actions for Node 24 runtime --- .github/workflows/deploy-v3.yml | 8 +++++--- .github/workflows/deploy.yml | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) 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 From 926f7255ddc01cedce081fadb171ce7aa476dc3b Mon Sep 17 00:00:00 2001 From: leazi99 Date: Wed, 29 Apr 2026 12:00:36 +0545 Subject: [PATCH 5/7] feat(overlay): add animated DropdownMenu, stories, docs, and preview fixes --- animata/card/state-action-card.stories.tsx | 6 +- animata/hero/product-features.tsx | 4 +- animata/overlay/dropdown-menu.stories.tsx | 63 +++ animata/overlay/dropdown-menu.tsx | 437 ++++++++++++++++++ .../section/pricing-comparison.stories.tsx | 134 ++++++ animata/section/pricing-comparison.tsx | 276 +++++++++++ components/component-preview.tsx | 150 +++--- components/mdx-components.tsx | 92 ++-- content/docs/card/state-action-card.mdx | 2 +- content/docs/carousel/swipe-deck.mdx | 2 +- content/docs/overlay/dropdown-menu.mdx | 59 +++ content/docs/section/pricing-comparison.mdx | 38 ++ 12 files changed, 1149 insertions(+), 114 deletions(-) create mode 100644 animata/overlay/dropdown-menu.stories.tsx create mode 100644 animata/overlay/dropdown-menu.tsx create mode 100644 animata/section/pricing-comparison.stories.tsx create mode 100644 animata/section/pricing-comparison.tsx create mode 100644 content/docs/overlay/dropdown-menu.mdx create mode 100644 content/docs/section/pricing-comparison.mdx diff --git a/animata/card/state-action-card.stories.tsx b/animata/card/state-action-card.stories.tsx index d1787e65..5f13e9a5 100644 --- a/animata/card/state-action-card.stories.tsx +++ b/animata/card/state-action-card.stories.tsx @@ -14,19 +14,19 @@ const meta = { export default meta; type Story = StoryObj; -export const TaskManager: Story = { +export const Taskmanager: Story = { args: { useCase: "task", }, }; -export const SocialCard: Story = { +export const Socialcard: Story = { args: { useCase: "social", }, }; -export const OrderCard: Story = { +export const Ordercard: Story = { args: { useCase: "order", }, 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..94038c21 --- /dev/null +++ b/animata/overlay/dropdown-menu.tsx @@ -0,0 +1,437 @@ +"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 placementClasses: Record, string> = { + "bottom-start": "left-0 top-full mt-2 origin-top-left", + "bottom-end": "right-0 top-full mt-2 origin-top-right", + "top-start": "bottom-full left-0 mb-2 origin-bottom-left", + "top-end": "bottom-full right-0 mb-2 origin-bottom-right", +}; + +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 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(); + } + }} + > + + + + {open && ( + + + + )} + +
+ ); +} 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 ? ( +