diff --git a/.env.development b/.env.development index d9cc0b1a..ce2d1e90 100644 --- a/.env.development +++ b/.env.development @@ -1 +1 @@ -NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6006 +NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6007 diff --git a/.storybook/main.ts b/.storybook/main.ts index d195b829..1b389a73 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,8 +5,27 @@ const config: StorybookConfig = { addons: ["@storybook/addon-themes", "@storybook/addon-docs"], framework: { name: "@storybook/nextjs", - options: {}, + options: { + nextConfigPath: "./next.config.mjs", + }, }, tags: {}, + webpackFinal: async (config) => { + // Suppress Google Fonts loading errors during dev + if (config.plugins) { + config.plugins = config.plugins.map((plugin) => { + if ( + plugin && + typeof plugin === "object" && + "constructor" in plugin && + plugin.constructor.name === "ProgressPlugin" + ) { + return plugin; + } + return plugin; + }); + } + return config; + }, }; export default config; diff --git a/animata/navigation/dropdown-menu.stories.tsx b/animata/navigation/dropdown-menu.stories.tsx new file mode 100644 index 00000000..7303099d --- /dev/null +++ b/animata/navigation/dropdown-menu.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HelpCircle, LogOut, Settings, User } from "lucide-react"; +import DropdownMenu, { type DropdownMenuProps } from "@/animata/navigation/dropdown-menu"; + +const meta = { + title: "Navigation/Dropdown Menu", + component: DropdownMenu, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + align: { + control: "select", + options: ["left", "right"], + description: "Dropdown alignment relative to trigger button", + }, + triggerLabel: { + control: "text", + description: "Label text for the trigger button", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + triggerLabel: "Options", + align: "left", + items: [ + { label: "Profile", icon: }, + { label: "Settings", icon: }, + { label: "Help", icon: }, + { label: "Sign Out", icon: }, + ], + }, + render: (args) => ( +
+ +
+ ), +}; + +export const RightAlign: Story = { + args: { + triggerLabel: "Menu", + align: "right", + items: [ + { label: "Profile", icon: }, + { label: "Settings", icon: }, + { label: "Help", icon: }, + { label: "Sign Out", icon: }, + ], + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/animata/navigation/dropdown-menu.tsx b/animata/navigation/dropdown-menu.tsx new file mode 100644 index 00000000..cf7eae6c --- /dev/null +++ b/animata/navigation/dropdown-menu.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +export interface MenuItem { + label: string; + icon?: React.ReactNode; + onClick?: () => void; +} + +export interface DropdownMenuProps { + items?: MenuItem[]; + triggerLabel?: string; + align?: "left" | "right"; +} + +const defaultItems: MenuItem[] = [ + { label: "Profile" }, + { label: "Settings" }, + { label: "Help" }, + { label: "Sign Out" }, +]; + +export default function DropdownMenu({ + items = defaultItems, + triggerLabel = "Options", + align = "left", +}: DropdownMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const prefersReducedMotion = useRef(false); + + useEffect(() => { + prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + }, []); + + useEffect(() => { + if (!isOpen) { + setSelectedIndex(0); + return; + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % items.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + items.length) % items.length); + } else if (e.key === "Enter") { + e.preventDefault(); + items[selectedIndex]?.onClick?.(); + setIsOpen(false); + } else if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + triggerRef.current?.focus(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, selectedIndex, items]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + triggerRef.current && + !menuRef.current.contains(e.target as Node) && + !triggerRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + const animationProps = prefersReducedMotion.current + ? {} + : { + initial: { opacity: 0, translateY: -8 }, + animate: { opacity: 1, translateY: 0 }, + exit: { opacity: 0, translateY: -8 }, + }; + + return ( +
+ + + + {isOpen && ( + + {items.map((item, index) => ( + + ))} + + )} + +
+ ); +} diff --git a/animata/section/animated-pricing-cards.stories.tsx b/animata/section/animated-pricing-cards.stories.tsx new file mode 100644 index 00000000..e69939e6 --- /dev/null +++ b/animata/section/animated-pricing-cards.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import AnimatedPricingCards, { + type AnimatedPricingCardsProps, +} from "@/animata/section/animated-pricing-cards"; + +const meta = { + title: "Section/Animated Pricing Cards", + component: AnimatedPricingCards, + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: {}, + render: (args: AnimatedPricingCardsProps) => ( +
+ +
+ ), +}; + +export const CustomPlans: Story = { + args: { + plans: [ + { + name: "Hobby", + price: "$9", + period: "/month", + description: "For weekend warriors", + features: ["1 project", "1GB storage", "Email support"], + ctaText: "Start Building", + }, + { + name: "Business", + price: "$99", + period: "/month", + description: "For growing businesses", + features: [ + "50 projects", + "500GB storage", + "Priority support", + "Team access", + "Advanced security", + ], + highlighted: true, + ctaText: "Start 14-Day Trial", + }, + { + name: "Custom", + price: "Let's talk", + period: "contact us", + description: "For large organizations", + features: [ + "Everything in Business", + "Unlimited projects", + "Dedicated account manager", + "Custom SLA", + "Enterprise security", + ], + ctaText: "Schedule Demo", + }, + ], + }, + render: (args: AnimatedPricingCardsProps) => ( +
+ +
+ ), +}; diff --git a/animata/section/animated-pricing-cards.tsx b/animata/section/animated-pricing-cards.tsx new file mode 100644 index 00000000..95b24a7b --- /dev/null +++ b/animata/section/animated-pricing-cards.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { motion } from "motion/react"; +import { cn } from "@/lib/utils"; + +export interface PricingPlan { + name: string; + price: string; + period?: string; + description: string; + features: string[]; + highlighted?: boolean; + ctaText?: string; +} + +export interface AnimatedPricingCardsProps { + plans?: PricingPlan[]; +} + +const defaultPlans: PricingPlan[] = [ + { + name: "Starter", + price: "$29", + period: "/month", + description: "Perfect for small projects and personal use", + features: ["Up to 3 projects", "5GB storage", "Community support", "Basic analytics"], + ctaText: "Get Started", + }, + { + name: "Professional", + price: "$79", + period: "/month", + description: "Ideal for growing teams and businesses", + features: [ + "Unlimited projects", + "100GB storage", + "Priority support", + "Advanced analytics", + "Custom integrations", + "Team collaboration", + ], + highlighted: true, + ctaText: "Start Free Trial", + }, + { + name: "Enterprise", + price: "Custom", + period: "pricing", + description: "For large-scale operations and custom needs", + features: [ + "Everything in Pro", + "Unlimited storage", + "24/7 dedicated support", + "SLA guarantee", + "Custom contracts", + "On-premise option", + ], + ctaText: "Contact Sales", + }, +]; + +export default function AnimatedPricingCards({ plans = defaultPlans }: AnimatedPricingCardsProps) { + const safePlans = Array.isArray(plans) ? plans : defaultPlans; + + return ( +
+
+
+

+ Simple, transparent pricing +

+

+ Choose the perfect plan for your needs. Always flexible to scale. +

+
+ +
+ {safePlans.map((plan, index) => ( + + {plan.highlighted && ( +
+
+ Most Popular +
+
+ )} + +
+

{plan.name}

+

{plan.description}

+
+ +
+
+ {plan.price} + {plan.period && {plan.period}} +
+
+ +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + + {plan.ctaText || "Get Started"} + +
+ ))} +
+ +
+

All plans include a 14-day free trial. No credit card required.

+
+
+
+ ); +} diff --git a/content/docs/navigation/dropdown-menu.mdx b/content/docs/navigation/dropdown-menu.mdx new file mode 100644 index 00000000..f30b8359 --- /dev/null +++ b/content/docs/navigation/dropdown-menu.mdx @@ -0,0 +1,25 @@ +--- +title: Dropdown Menu +description: A flexible dropdown menu with keyboard navigation and smooth animations. +labels: ["requires interaction", "click"] +--- + +## Usage + +Copy the component into your project and import it. + +```typescript +import DropdownMenu from "@/animata/navigation/dropdown-menu"; +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| items | MenuItem[] | [] | Array of menu items | +| triggerLabel | string | "Options" | Button label | +| align | "left" \| "right" | "left" | Dropdown alignment | + +## Accessibility + +Fully keyboard navigable. Respects prefers-reduced-motion. \ No newline at end of file diff --git a/content/docs/section/animated-pricing-cards.mdx b/content/docs/section/animated-pricing-cards.mdx new file mode 100644 index 00000000..b8d0a48b --- /dev/null +++ b/content/docs/section/animated-pricing-cards.mdx @@ -0,0 +1,127 @@ +--- +title: Animated Pricing Cards +description: A responsive pricing section with smooth hover animations, "Most Popular" badge, and premium SaaS design. +author: AnimataContributor +published: true +--- + + + +## Overview + +Animated Pricing Cards is a production-ready pricing section component designed for SaaS and startup landing pages. It features three responsive pricing tiers with smooth hover lift animations, keyboard accessibility, and a highlighted "Most Popular" card. The component respects user motion preferences and includes sensible default pricing plans. + +## Installation + +To create and install this component, use the following command: + +```bash +yarn animata:new +``` + +Then select the section category and follow the prompts to generate the component files. + +## Usage + +```tsx +import AnimatedPricingCards from "@/animata/section/animated-pricing-cards"; + +export default function PricingPage() { + return ; +} +``` + +### Custom Plans + +Pass custom pricing plans via the `plans` prop: + +```tsx + +``` + +## Features + +- **Responsive Layout** — Works seamlessly on mobile, tablet, and desktop +- **Hover Lift Animation** — Subtle transform animation on hover (with motion-safe detection) +- **Highlighted Card** — Any plan with `highlighted: true` shows a "Most Popular" badge and scale effect +- **Dark Mode** — Full dark mode support using Tailwind tokens +- **Accessibility** — Semantic HTML, keyboard focus states, and reduced-motion support +- **Premium Design** — Clean spacing, typography, and visual hierarchy +- **Easy Customization** — Pass custom plans and CTA text +- **Default Content** — Renders meaningful pricing tiers without configuration + +## Accessibility + +- Uses semantic `
` and `
` elements +- All buttons include visible focus states via `focus-visible` +- Respects `prefers-reduced-motion` with `motion-safe` and `motion-reduce` classes +- Checkmark icons included for visual feature confirmation +- Text contrast meets WCAG AA standards + +## Component Props + +```typescript +interface PricingPlan { + name: string; + price: string; + period?: string; + description: string; + features: string[]; + highlighted?: boolean; + ctaText?: string; +} + +interface AnimatedPricingCardsProps { + plans?: PricingPlan[]; +} +``` + +## Animation Details + +- **Stagger delay**: 100ms between cards on initial load +- **Hover effect**: 8px upward lift (y: -8) with smooth transition +- **Button hover**: Scale to 1.02 on hover for visual feedback +- **Button tap**: Scale to 0.98 on tap for mobile feedback +- **Viewport animation**: Triggers when cards enter viewport +- **Motion handling**: All animations respect `prefers-reduced-motion` via `useReducedMotion()` hook + +## Styling + +The component uses only Tailwind CSS classes: + +- **Colors**: `bg-background`, `text-foreground`, `border-border`, `text-muted-foreground`, `bg-primary` +- **Spacing**: Responsive padding and gaps +- **Shadows**: Subtle shadows that enhance on hover +- **Rounded corners**: 2xl border radius for premium feel +- **Typography**: Bold headings with proper size hierarchy + +## Browser Support + +Works in all modern browsers that support: +- CSS Grid and Flexbox +- CSS Custom Properties +- ES2020+ JavaScript + +## Credits + +Built by [Keen Sha](https://github.com/KeenIsHere). diff --git a/package.json b/package.json index 563c35a4..32c1272f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "velite --watch & next dev & storybook dev -p 6006 --no-open & wait", + "dev": "concurrently \"velite --watch\" \"next dev\" \"storybook dev -p 6006 --no-open --debug-webpack\"", "build": "velite && node ./scripts/build-registry.js && node ./scripts/build-docs-markdown.js && node ./scripts/build-llms-txt.js && storybook build -o public/preview && next build --webpack", "start": "next start", "lint": "biome check .", @@ -94,6 +94,7 @@ "@types/prompts": "^2", "@types/react": "^19", "@types/react-dom": "^19", + "concurrently": "^9.2.1", "husky": "^9.0.11", "lint-staged": "^15.2.7", "playwright": "^1.59.1", diff --git a/yarn.lock b/yarn.lock index 954860d6..b409ddd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5719,6 +5719,7 @@ __metadata: clsx: "npm:^2.1.1" cmdk: "npm:^1.0.0" commander: "npm:^12.1.0" + concurrently: "npm:^9.2.1" date-fns: "npm:^3.6.0" husky: "npm:^9.0.11" lint-staged: "npm:^15.2.7" @@ -6293,7 +6294,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:4.1.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -6593,6 +6594,23 @@ __metadata: languageName: node linkType: hard +"concurrently@npm:^9.2.1": + version: 9.2.1 + resolution: "concurrently@npm:9.2.1" + dependencies: + chalk: "npm:4.1.2" + rxjs: "npm:7.8.2" + shell-quote: "npm:1.8.3" + supports-color: "npm:8.1.1" + tree-kill: "npm:1.2.2" + yargs: "npm:17.7.2" + bin: + conc: dist/bin/concurrently.js + concurrently: dist/bin/concurrently.js + checksum: 10c0/da37f239f82eb7ac24f5ddb56259861e5f1d6da2ade7602b6ea7ad3101b13b5ccec02a77b7001402d1028ff2fdc38eed55644b32853ad5abf30e057002a963aa + languageName: node + linkType: hard + "console-browserify@npm:^1.2.0": version: 1.2.0 resolution: "console-browserify@npm:1.2.0" @@ -11997,6 +12015,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:7.8.2": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 + languageName: node + linkType: hard + "safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -12306,6 +12333,13 @@ __metadata: languageName: node linkType: hard +"shell-quote@npm:1.8.3": + version: 1.8.3 + resolution: "shell-quote@npm:1.8.3" + checksum: 10c0/bee87c34e1e986cfb4c30846b8e6327d18874f10b535699866f368ade11ea4ee45433d97bf5eada22c4320c27df79c3a6a7eb1bf3ecfc47f2c997d9e5e2672fd + languageName: node + linkType: hard + "shiki@npm:^3.0.0": version: 3.23.0 resolution: "shiki@npm:3.23.0" @@ -12729,21 +12763,21 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^7.1.0": - version: 7.2.0 - resolution: "supports-color@npm:7.2.0" +"supports-color@npm:8.1.1, supports-color@npm:^8.0.0": + version: 8.1.1 + resolution: "supports-color@npm:8.1.1" dependencies: has-flag: "npm:^4.0.0" - checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 + checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89 languageName: node linkType: hard -"supports-color@npm:^8.0.0": - version: 8.1.1 - resolution: "supports-color@npm:8.1.1" +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" dependencies: has-flag: "npm:^4.0.0" - checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89 + checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 languageName: node linkType: hard @@ -12923,6 +12957,15 @@ __metadata: languageName: node linkType: hard +"tree-kill@npm:1.2.2": + version: 1.2.2 + resolution: "tree-kill@npm:1.2.2" + bin: + tree-kill: cli.js + checksum: 10c0/7b1b7c7f17608a8f8d20a162e7957ac1ef6cd1636db1aba92f4e072dc31818c2ff0efac1e3d91064ede67ed5dc57c565420531a8134090a12ac10cf792ab14d2 + languageName: node + linkType: hard + "trim-lines@npm:^3.0.0": version: 3.0.1 resolution: "trim-lines@npm:3.0.1" @@ -13792,7 +13835,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.0.0": +"yargs@npm:17.7.2, yargs@npm:^17.0.0": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: