$npx -y skills add vibeeval/vibecosystem --skill animation-patternsFramer Motion patterns, page transitions, skeleton loading, scroll-linked animations, and gesture-based interactions for React.
| 1 | # Animation Patterns |
| 2 | |
| 3 | Framer Motion patterns for production-quality React animations. |
| 4 | |
| 5 | ## Framer Motion Basics (motion components, variants) |
| 6 | |
| 7 | ```typescript |
| 8 | // Install: npm install framer-motion |
| 9 | |
| 10 | import { motion } from 'framer-motion' |
| 11 | |
| 12 | // Basic motion component |
| 13 | <motion.div |
| 14 | initial={{ opacity: 0, y: 20 }} |
| 15 | animate={{ opacity: 1, y: 0 }} |
| 16 | transition={{ duration: 0.3, ease: 'easeOut' }} |
| 17 | /> |
| 18 | |
| 19 | // Variants — define states, animate by name |
| 20 | const cardVariants = { |
| 21 | hidden: { opacity: 0, y: 20, scale: 0.98 }, |
| 22 | visible: { |
| 23 | opacity: 1, |
| 24 | y: 0, |
| 25 | scale: 1, |
| 26 | transition: { duration: 0.25, ease: [0.16, 1, 0.3, 1] }, |
| 27 | }, |
| 28 | hover: { |
| 29 | y: -4, |
| 30 | boxShadow: '0 12px 24px -4px rgb(0 0 0 / 0.15)', |
| 31 | transition: { duration: 0.2 }, |
| 32 | }, |
| 33 | } |
| 34 | |
| 35 | function Card({ children }: { children: React.ReactNode }) { |
| 36 | return ( |
| 37 | <motion.div |
| 38 | variants={cardVariants} |
| 39 | initial="hidden" |
| 40 | animate="visible" |
| 41 | whileHover="hover" |
| 42 | className="rounded-lg border bg-white p-4" |
| 43 | > |
| 44 | {children} |
| 45 | </motion.div> |
| 46 | ) |
| 47 | } |
| 48 | ``` |
| 49 | |
| 50 | ## Page Transitions with AnimatePresence |
| 51 | |
| 52 | ```typescript |
| 53 | import { AnimatePresence, motion } from 'framer-motion' |
| 54 | import { usePathname } from 'next/navigation' |
| 55 | |
| 56 | const pageVariants = { |
| 57 | initial: { opacity: 0, x: -8 }, |
| 58 | enter: { opacity: 1, x: 0, transition: { duration: 0.2, ease: 'easeOut' } }, |
| 59 | exit: { opacity: 0, x: 8, transition: { duration: 0.15, ease: 'easeIn' } }, |
| 60 | } |
| 61 | |
| 62 | // app/layout.tsx (Next.js App Router) |
| 63 | export default function RootLayout({ children }: { children: React.ReactNode }) { |
| 64 | const pathname = usePathname() |
| 65 | |
| 66 | return ( |
| 67 | <html> |
| 68 | <body> |
| 69 | <AnimatePresence mode="wait"> |
| 70 | <motion.main |
| 71 | key={pathname} |
| 72 | variants={pageVariants} |
| 73 | initial="initial" |
| 74 | animate="enter" |
| 75 | exit="exit" |
| 76 | > |
| 77 | {children} |
| 78 | </motion.main> |
| 79 | </AnimatePresence> |
| 80 | </body> |
| 81 | </html> |
| 82 | ) |
| 83 | } |
| 84 | |
| 85 | // Modal transitions — mount/unmount |
| 86 | function Modal({ isOpen, onClose, children }: ModalProps) { |
| 87 | return ( |
| 88 | <AnimatePresence> |
| 89 | {isOpen && ( |
| 90 | <> |
| 91 | <motion.div |
| 92 | key="overlay" |
| 93 | className="fixed inset-0 bg-black/40" |
| 94 | initial={{ opacity: 0 }} |
| 95 | animate={{ opacity: 1 }} |
| 96 | exit={{ opacity: 0 }} |
| 97 | onClick={onClose} |
| 98 | /> |
| 99 | <motion.div |
| 100 | key="modal" |
| 101 | className="fixed inset-x-4 top-[10%] mx-auto max-w-lg rounded-xl bg-white p-6 shadow-xl" |
| 102 | initial={{ opacity: 0, scale: 0.96, y: 16 }} |
| 103 | animate={{ opacity: 1, scale: 1, y: 0 }} |
| 104 | exit={{ opacity: 0, scale: 0.96, y: 8 }} |
| 105 | transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} |
| 106 | > |
| 107 | {children} |
| 108 | </motion.div> |
| 109 | </> |
| 110 | )} |
| 111 | </AnimatePresence> |
| 112 | ) |
| 113 | } |
| 114 | ``` |
| 115 | |
| 116 | ## Layout Animations (Shared Layout, Layout ID) |
| 117 | |
| 118 | ```typescript |
| 119 | import { motion, LayoutGroup } from 'framer-motion' |
| 120 | import { useState } from 'react' |
| 121 | |
| 122 | // Tabs with animated indicator |
| 123 | function AnimatedTabs({ tabs }: { tabs: string[] }) { |
| 124 | const [active, setActive] = useState(tabs[0]) |
| 125 | |
| 126 | return ( |
| 127 | <LayoutGroup> |
| 128 | <div className="flex gap-1 rounded-lg bg-gray-100 p-1"> |
| 129 | {tabs.map(tab => ( |
| 130 | <button |
| 131 | key={tab} |
| 132 | onClick={() => setActive(tab)} |
| 133 | className="relative px-4 py-2 text-sm font-medium" |
| 134 | > |
| 135 | {active === tab && ( |
| 136 | <motion.div |
| 137 | layoutId="tab-indicator" // shared across tabs |
| 138 | className="absolute inset-0 rounded-md bg-white shadow-sm" |
| 139 | transition={{ type: 'spring', stiffness: 400, damping: 30 }} |
| 140 | /> |
| 141 | )} |
| 142 | <span className="relative z-10">{tab}</span> |
| 143 | </button> |
| 144 | ))} |
| 145 | </div> |
| 146 | </LayoutGroup> |
| 147 | ) |
| 148 | } |
| 149 | |
| 150 | // Expandable card with layout animation |
| 151 | function ExpandableCard({ title, body }: { title: string; body: string }) { |
| 152 | const [expanded, setExpanded] = useState(false) |
| 153 | |
| 154 | return ( |
| 155 | <motion.div |
| 156 | layout // animate height changes |
| 157 | className="rounded-lg border bg-white p-4 cursor-pointer" |
| 158 | onClick={() => setExpanded(e => !e)} |
| 159 | > |
| 160 | <motion.h3 layout="position" className="font-semibold">{title}</motion.h3> |
| 161 | <AnimatePresence> |
| 162 | {expanded && ( |
| 163 | <motion.p |
| 164 | initial={{ opacity: 0 }} |
| 165 | animate={{ opacity: 1 }} |
| 166 | exit={{ opacity: 0 }} |
| 167 | className="mt-2 text-gray-600 text-sm" |
| 168 | > |
| 169 | {body} |
| 170 | </motion.p> |
| 171 | )} |
| 172 | </AnimatePresence> |
| 173 | </motion.div> |
| 174 | ) |
| 175 | } |
| 176 | ``` |
| 177 | |
| 178 | ## Skeleton Loading Components |
| 179 | |
| 180 | ```typescript |
| 181 | // Base skeleton primitive |
| 182 | function Skeleton({ className }: { className?: string }) { |
| 183 | return ( |
| 184 | <div |
| 185 | className={cn('animate-pulse r |