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/primitive/animated-background-wrapper.stories.tsx b/animata/primitive/animated-background-wrapper.stories.tsx new file mode 100644 index 00000000..eacc3e21 --- /dev/null +++ b/animata/primitive/animated-background-wrapper.stories.tsx @@ -0,0 +1,200 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import AnimatedBackgroundWrapper from "@/animata/primitive/animated-background-wrapper"; + +const meta = { + title: "Primitive/Animated Background Wrapper", + component: AnimatedBackgroundWrapper, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Content blocks ─────────────────────────────────────────────────────────── + +const HeroContent = () => ( +
+ + New — Animata v2 + +

+ Build interfaces +
+ that feel alive. +

+

+ Copy-paste animated components for React. Zero config. Drop-in ready. +

+
+ + +
+
+); + +const CTAContent = () => ( +
+
+ Limited beta access +
+

+ Ship faster. +
+ Look better. +

+

+ The animation library that makes your product impossible to ignore. +

+ +
+); + +const FeatureContent = () => ( +
+
+
+ ⚡ +
+
+

Zero runtime

+

+ Pure CSS animations. No JS loops. Silky smooth on any device. +

+
+
+
+
+ 🎨 +
+
+

Design-token aware

+

+ Respects your Tailwind theme. Dark mode and brand colors — automatic. +

+
+
+
+
+ ♿ +
+
+

Accessible

+

+ Fully respects prefers-reduced-motion. Beautiful for everyone. +

+
+
+
+); + +const ParticleContent = () => ( +
+

+ Magic in the details +

+

+ Every pixel, +
+ intentional. +

+

+ Forty lines. Infinite possibilities. Just drop it in. +

+
+); + +// ─── Stories ────────────────────────────────────────────────────────────────── + +export const Primary: Story = { + args: { + variant: "aurora", + intensity: "medium", + }, + render: (args) => ( +
+ + + +
+ ), +}; + +export const Aurora: Story = { + args: { + variant: "aurora", + intensity: "medium", + }, + render: (args) => ( +
+ + + +
+ ), +}; + +export const Beam: Story = { + args: { + variant: "beam", + intensity: "medium", + }, + render: (args) => ( + + + + ), +}; + +export const Grid: Story = { + args: { + variant: "grid", + intensity: "medium", + }, + render: (args) => ( + + + + ), +}; + +export const Particles: Story = { + args: { + variant: "particles", + intensity: "medium", + }, + render: (args) => ( + + + + ), +}; + +export const IntensityShowcase: Story = { + args: {}, + render: () => ( +
+ {(["subtle", "medium", "strong"] as const).map((intensity) => ( +
+ +
+ + {intensity} + + {intensity} +
+
+
+ ))} +
+ ), +}; diff --git a/animata/primitive/animated-background-wrapper.tsx b/animata/primitive/animated-background-wrapper.tsx new file mode 100644 index 00000000..a814e371 --- /dev/null +++ b/animata/primitive/animated-background-wrapper.tsx @@ -0,0 +1,298 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Variant = "aurora" | "beam" | "grid" | "particles"; +type Intensity = "subtle" | "medium" | "strong"; + +export interface AnimatedBackgroundProps { + variant?: Variant; + children?: ReactNode; + className?: string; + intensity?: Intensity; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const INTENSITY_SCALE: Record = { + subtle: 0.5, + medium: 1, + strong: 1.5, +}; + +// Deterministic particle data — avoids hydration mismatch from Math.random() +const PARTICLE_DATA = [ + { x: 5, size: 3, duration: 14, delay: 0, opacity: 0.3 }, + { x: 12, size: 2, duration: 18, delay: 2.5, opacity: 0.2 }, + { x: 20, size: 4, duration: 11, delay: 1.0, opacity: 0.35 }, + { x: 28, size: 2, duration: 16, delay: 4.0, opacity: 0.25 }, + { x: 35, size: 3, duration: 20, delay: 0.5, opacity: 0.18 }, + { x: 42, size: 5, duration: 13, delay: 3.0, opacity: 0.28 }, + { x: 50, size: 2, duration: 17, delay: 1.5, opacity: 0.22 }, + { x: 58, size: 4, duration: 12, delay: 5.0, opacity: 0.32 }, + { x: 65, size: 3, duration: 19, delay: 2.0, opacity: 0.2 }, + { x: 72, size: 2, duration: 15, delay: 3.5, opacity: 0.28 }, + { x: 78, size: 5, duration: 10, delay: 0.8, opacity: 0.15 }, + { x: 85, size: 3, duration: 22, delay: 4.5, opacity: 0.35 }, + { x: 90, size: 2, duration: 14, delay: 1.2, opacity: 0.22 }, + { x: 8, size: 4, duration: 16, delay: 6.0, opacity: 0.28 }, + { x: 18, size: 3, duration: 13, delay: 2.8, opacity: 0.18 }, + { x: 32, size: 2, duration: 21, delay: 7.0, opacity: 0.3 }, + { x: 45, size: 5, duration: 15, delay: 1.8, opacity: 0.2 }, + { x: 60, size: 3, duration: 18, delay: 3.2, opacity: 0.25 }, + { x: 75, size: 2, duration: 11, delay: 5.5, opacity: 0.32 }, + { x: 92, size: 4, duration: 20, delay: 0.3, opacity: 0.18 }, +] as const; + +interface AuroraBlobConfig { + keyframe: string; + duration: string; + color: string; + position: string; + baseOpacity: number; +} + +const AURORA_BLOBS: AuroraBlobConfig[] = [ + { + keyframe: "animata-aurora-1", + duration: "15s", + color: "bg-violet-500", + position: "left-[-10%] top-[-10%]", + baseOpacity: 0.4, + }, + { + keyframe: "animata-aurora-2", + duration: "20s", + color: "bg-blue-500", + position: "left-[55%] top-[-5%]", + baseOpacity: 0.35, + }, + { + keyframe: "animata-aurora-3", + duration: "18s", + color: "bg-indigo-500", + position: "left-[20%] top-[40%]", + baseOpacity: 0.3, + }, + { + keyframe: "animata-aurora-4", + duration: "22s", + color: "bg-fuchsia-500", + position: "left-[-5%] top-[20%]", + baseOpacity: 0.25, + }, +]; + +const KEYFRAME_STYLES = ` +@keyframes animata-aurora-1 { + 0%, 100% { transform: translate(0%, 0%) scale(1); } + 25% { transform: translate(30%, -20%) scale(1.10); } + 50% { transform: translate(15%, 10%) scale(0.95); } + 75% { transform: translate(-10%, 15%) scale(1.05); } +} +@keyframes animata-aurora-2 { + 0%, 100% { transform: translate(0%, 0%) scale(1.05); } + 30% { transform: translate(-35%, 25%) scale(1.00); } + 65% { transform: translate(-15%, -20%) scale(1.08); } +} +@keyframes animata-aurora-3 { + 0%, 100% { transform: translate(0%, 0%) scale(1.00); } + 33% { transform: translate(-25%, -35%) scale(1.10); } + 66% { transform: translate(20%, -15%) scale(0.95); } +} +@keyframes animata-aurora-4 { + 0%, 100% { transform: translate(0%, 0%) scale(1.02); } + 25% { transform: translate(35%, -25%) scale(0.95); } + 50% { transform: translate(15%, 10%) scale(1.05); } + 75% { transform: translate(45%, 20%) scale(1.00); } +} +@keyframes animata-beam-sweep { + 0% { transform: translateX(-200%) rotate(-45deg); } + 100% { transform: translateX(300%) rotate(-45deg); } +} +@keyframes animata-grid-breathe { + 0%, 100% { transform: perspective(600px) rotateX(50deg) scale(2.00) translateY(-5%); } + 50% { transform: perspective(600px) rotateX(50deg) scale(2.05) translateY(-5%); } +} +@keyframes animata-float-up { + 0% { transform: translateY(110vh); opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { transform: translateY(-5vh); opacity: 0; } +} +`; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function clampOpacity(v: number): number { + return Math.min(Math.max(v, 0), 1); +} + +// ─── Variant sub-components ─────────────────────────────────────────────────── + +function AuroraBackground({ reduced, intensity }: { reduced: boolean; intensity: Intensity }) { + const scale = INTENSITY_SCALE[intensity]; + + return ( + <> + {AURORA_BLOBS.map((blob, i) => ( +