
Motion
- 85 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
motion is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- motion
- AI & Agent Building
- AI-coding skill
Motion by the numbers
- 85 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,069 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill motionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 85 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Motion
Overview
Motion (package: motion, formerly framer-motion) is the standard React animation library. Import from motion/react. Provides declarative props for gestures, scroll-linked animations, layout transitions, SVG path drawing, and spring physics. Uses a hybrid animation engine (WAAPI for transforms/opacity, ScrollTimeline for scroll-linked effects). Bundle ranges from 2.3 KB (useAnimate mini) to 34 KB (full), optimizable to 4.6 KB with LazyMotion. Compatible with React 18.2+, React 19, Next.js App Router, and Vite.
Do NOT use Motion for simple list add/remove animations (use AutoAnimate instead at 3.28 KB). Do NOT use for 3D (use Three.js / React Three Fiber).
Quick Reference
| Pattern | API / Props |
|---|---|
| Fade in on mount | initial, animate, transition |
| Exit animations | AnimatePresence + exit prop (unique key required) |
| Staggered list | variants with staggerChildren |
| Hover / tap / focus | whileHover, whileTap, whileFocus |
| Drag | drag, dragConstraints, dragElastic |
| Scroll-triggered | whileInView, viewport={{ once: true }} |
| Scroll-linked/parallax | useScroll + useTransform |
| Progress indicator | scrollYProgress + scaleX |
| Layout animation | layout prop (FLIP technique) |
| Shared element | layoutId (same ID across views) |
| Layout group | LayoutGroup wrapping sibling lists |
| Page transition | AnimatePresence + key={pathname} |
| SVG path drawing | pathLength on motion.path |
| Animated counter | useSpring + useTransform |
| Imperative control | useAnimate hook returns [scope, animate] |
| Custom components | motion.create(Component) wraps any component |
| Bundle optimization | LazyMotion + domAnimation + m component (4.6 KB) |
| Reduced motion | MotionConfig reducedMotion="user" |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| AnimatePresence inside a conditional | Keep AnimatePresence mounted; place conditional content inside it |
Missing unique key on AnimatePresence children | Add unique key to each direct child for exit animations |
Tailwind transition-* classes with Motion animate props | Remove Tailwind transition classes to avoid stuttering |
Importing from framer-motion | Use import { motion } from "motion/react" (renamed late 2024) |
| Animating 50+ items without virtualization | Use react-window or @tanstack/react-virtual for large lists |
| Full 34 KB bundle for simple animations | Use LazyMotion + domAnimation (4.6 KB) or useAnimate (2.3 KB) |
Missing "use client" in Next.js App Router | Add directive or use motion/react-client import |
Animating width/height directly | Use layout prop or transform: scale for GPU acceleration |
No prefers-reduced-motion handling | Wrap app in MotionConfig reducedMotion="user" |
Delegation
- Audit animation performance and bundle size: Use
Exploreagent to find heavy imports, missing LazyMotion, and reflow-triggering properties - Build complex multi-step animations: Use
Taskagent for scroll-linked parallax, shared layout transitions, and staggered sequences - Plan animation architecture for a new project: Use
Planagent to evaluate Motion vs AutoAnimate vs CSS-only based on requirements
If the design-system skill is available, delegate animation token definitions and motion design guidelines to it.References
- Core Patterns -- Animation patterns: fade, exit, stagger, gestures, modal, accordion, tabs, scroll, layout, drag, SVG, loading
- Scroll Animations -- useScroll, useTransform, scroll-triggered, parallax, progress indicators, offset configuration
- Performance -- LazyMotion, useAnimate, hardware acceleration, virtualization, production checklist
- Next.js Integration -- App Router patterns, motion/react-client, Pages Router, known issues
- Accessibility & CSS -- prefers-reduced-motion, MotionConfig, useReducedMotion, CSS keyframes, Tailwind animations
- Library Selection Guide -- Motion vs AutoAnimate decision guide with feature comparison and use-case recommendations
- Troubleshooting -- AnimatePresence bugs, Tailwind conflicts, layout glitches, React 19 issues, naming migration
Accessibility & CSS Animations
prefers-reduced-motion
Global (Recommended)
import { MotionConfig } from 'motion/react';
<MotionConfig reducedMotion="user">
<App />
</MotionConfig>;Options: "user" (respects OS setting), "always" (force instant), "never" (ignore preference).
Place in root layout or app entry point so all Motion components inherit the setting.
Per-Component
import { useReducedMotion } from 'motion/react';
function AnimatedCard() {
const shouldReduce = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduce ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduce ? 0 : 0.4 }}
/>
);
}Manual Media Query (Custom Behavior)
For cases where you need full control beyond MotionConfig:
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const handler = () => setPrefersReducedMotion(mq.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
<motion.div
initial={{ opacity: prefersReducedMotion ? 1 : 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: prefersReducedMotion ? 1 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.3 }}
/>;Accessibility Patterns
When reduced motion is enabled, preserve meaning while removing movement:
function AccessibleReveal({ children }: { children: ReactNode }) {
const shouldReduce = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduce ? 0 : 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: shouldReduce ? 0.1 : 0.5 }}
>
{children}
</motion.div>
);
}Key principle: keep opacity fade (instant or near-instant) for content reveal, but remove spatial movement (y, x, scale) when reduced motion is preferred.
Testing Reduced Motion
- macOS: System Settings > Accessibility > Display > Reduce motion
- Windows: Settings > Ease of Access > Display > Show animations
- iOS: Settings > Accessibility > Motion
- Android 9+: Settings > Accessibility > Remove animations
- Chrome DevTools: Rendering tab > Emulate CSS media feature
prefers-reduced-motion
CSS-Only Animations
For cases where Motion is not needed.
CSS Keyframes
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
opacity: 1;
}
}Tailwind Custom Animations (v4)
Define custom animations in CSS using @theme (Tailwind v4+):
@theme {
--animate-fade-in: fade-in 0.5s ease-out;
--animate-slide-up: slide-up 0.3s ease-out;
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}Usage: <div className="animate-fade-in">Fades in</div>
When to Use CSS vs Motion
| Use CSS When | Use Motion When |
|---|---|
| Simple hover effects | Complex gesture interactions |
| Basic enter animations | Exit animations needed |
| No JavaScript interaction needed | Scroll-linked values |
| Performance-critical (0 JS) | Spring physics or layout transitions |
| Server-rendered static content | Orchestrated sequences |
Reorder (Drag-to-Reorder Lists)
import { Reorder } from 'motion/react';
function ReorderList({
items,
onReorder,
}: {
items: string[];
onReorder: (items: string[]) => void;
}) {
return (
<Reorder.Group axis="y" values={items} onReorder={onReorder}>
{items.map((item) => (
<Reorder.Item key={item} value={item}>
{item}
</Reorder.Item>
))}
</Reorder.Group>
);
}Limitations: Reorder uses layout animations internally. Avoid wrapping items in AnimatePresence — use layout prop on items instead for entry/exit effects.
Core Animation Patterns
Import for all examples: import { motion, AnimatePresence } from "motion/react"
Fade In on Mount
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
Content
</motion.div>Exit Animations (AnimatePresence)
<AnimatePresence>
{isVisible && (
<motion.div
key="unique"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
Content
</motion.div>
)}
</AnimatePresence>AnimatePresence must stay mounted. All children need unique key props.
Staggered List with Variants
const container = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.1 } },
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
<motion.ul variants={container} initial="hidden" animate="show">
{items.map((text) => (
<motion.li key={text} variants={item}>
{text}
</motion.li>
))}
</motion.ul>;Variants flow down to children automatically. Parent orchestrates timing with staggerChildren and delayChildren.
Gesture Animations
Hover and Tap
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
Click me
</motion.button>Focus
<motion.input
whileFocus={{ borderColor: '#3b82f6', boxShadow: '0 0 0 2px #3b82f6' }}
transition={{ duration: 0.2 }}
/>Drag
<motion.div
drag
dragConstraints={{ left: 0, right: 300, top: 0, bottom: 300 }}
dragElastic={0.1}
whileDrag={{ scale: 1.1 }}
className="cursor-grab active:cursor-grabbing"
/>Variants with Gestures
const buttonVariants = {
hover: { scale: 1.1 },
tap: { scale: 0.95 },
};
<motion.button whileTap="tap" whileHover="hover" variants={buttonVariants}>
Button
</motion.button>;Variant names in gesture props propagate to children with matching variants.
Modal Dialog
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50 z-40"
/>
<motion.div
key="dialog"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed inset-0 flex items-center justify-center z-50 p-4"
>
<div className="bg-white rounded-lg p-6 max-w-md w-full">
{children}
</div>
</motion.div>
</>
)}
</AnimatePresence>Accordion (Animate Height)
<motion.div
animate={{ height: isOpen ? 'auto' : 0 }}
style={{ overflow: 'hidden' }}
transition={{ duration: 0.3 }}
>
<div className="p-4">Content</div>
</motion.div>Tabs with Shared Underline (layoutId)
<div className="flex gap-4 border-b relative">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActive(tab.id)}
className="relative pb-2"
>
{tab.label}
{active === tab.id && (
<motion.div
layoutId="underline"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
/>
)}
</button>
))}
</div>Layout Animations (FLIP)
<motion.div layout>{isExpanded ? <FullContent /> : <Summary />}</motion.div>Options for layout prop: true (animate position and size), "position" (position only), "size" (size only).
Shared Element Transitions
<motion.div layoutId="card-1">Card content</motion.div>When a new element with the same layoutId enters the DOM, it animates from the previous element's position and size.
Layout Performance
<motion.nav layout layoutDependency={isOpen} />layoutDependency reduces measurements -- layout changes are only detected when this value changes instead of every render.
Special Layout Props
layoutScroll: Add to scrollable containers so layout animations account for scroll offsetlayoutRoot: Add toposition: fixedcontainers so layout animations account for page scroll
Page Transition
'use client';
import { motion } from 'motion/react';
<motion.div
key={pathname}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>;Loading Animations
Spinner
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full"
/>Skeleton Loader
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
className="bg-gray-200 rounded h-4 w-full"
/>Pulsing Dots
{
[0, 1, 2].map((i) => (
<motion.div
key={i}
animate={{ scale: [0.8, 1.2], opacity: [0.5, 1] }}
transition={{
duration: 0.6,
repeat: Infinity,
repeatType: 'reverse',
delay: i * 0.2,
}}
className="w-3 h-3 bg-blue-600 rounded-full"
/>
));
}SVG Path Drawing
<motion.svg width="48" height="48" viewBox="0 0 48 48">
<motion.circle
cx="24"
cy="24"
r="22"
fill="none"
stroke="#10B981"
strokeWidth="4"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.5 }}
/>
<motion.path
d="M12 24 L20 32 L36 16"
fill="none"
stroke="#10B981"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
/>
</motion.svg>SVG path animations work with pathLength, pathSpacing, and pathOffset (values between 0 and 1). Compatible with circle, ellipse, line, path, polygon, polyline, and rect elements.
Animated Number Counter
import { useSpring, useTransform } from 'motion/react';
const spring = useSpring(0, { stiffness: 100, damping: 30 });
const display = useTransform(spring, (v) => Math.round(v).toLocaleString());
useEffect(() => {
spring.set(value);
}, [spring, value]);
<motion.span>{display}</motion.span>;Toast Notification
<AnimatePresence>
{isVisible && (
<motion.div
key="toast"
initial={{ opacity: 0, x: 300 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 300 }}
className="fixed top-4 right-4 bg-blue-600 text-white p-4 rounded"
>
Message
</motion.div>
)}
</AnimatePresence>Notification Badge (Micro-interaction)
{
count > 0 && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="absolute -top-1 -right-1 bg-red-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
>
{count}
</motion.div>
);
}Carousel (Drag)
<motion.div
drag="x"
dragConstraints={{ left: -width, right: 0 }}
className="flex"
>
{images.map((img) => (
<img key={img.id} src={img.url} alt={img.alt} />
))}
</motion.div>Custom Components with motion.create
import { motion } from 'motion/react';
const MotionCard = motion.create(Card);
<MotionCard
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
whileHover={{ y: -4 }}
/>;For custom SVG components, pass { type: "svg" }:
const MotionIcon = motion.create(MyIcon, { type: 'svg' });Motion vs AutoAnimate Selection Guide
Quick Decision
Use AutoAnimate (3.28 KB, zero config) when:
- Animating list add/remove/sort operations
- Simple accordion expand/collapse
- Toast notifications, form validation errors
- Bundle size is critical
- Multi-framework (Vue, Svelte, vanilla JS)
Use Motion when:
- Gestures (drag, hover with fine control)
- Scroll-based animations or parallax
- Layout/shared element transitions
- SVG path morphing or line drawing
- Spring physics customization
- Complex choreographed animations
Rule of thumb: AutoAnimate for 80–90% of cases, Motion for the rest.
Feature Comparison
| Feature | AutoAnimate | Motion |
|---|---|---|
| List add/remove | Automatic | Manual setup |
| List reorder | Automatic | Manual setup |
| Accordion | Automatic | Manual setup |
| Drag gestures | Not supported | Full control |
| Hover/tap states | Not supported | whileHover, whileTap |
| Scroll animations | Not supported | whileInView, useScroll |
| Parallax | Not supported | useTransform |
| Layout/shared elements | Not supported | layout prop, layoutId |
| SVG animations | Not supported | path, line drawing |
| Spring physics | Not customizable | Full control |
| Exit animations | Automatic | AnimatePresence |
| prefers-reduced-motion | Automatic | Manual (MotionConfig) |
| Framework support | React, Vue, Svelte, JS | React only |
Bundle Size
| Package | Size | Best For |
|---|---|---|
| AutoAnimate | 3.28 KB | Simple list animations |
| Motion useAnimate mini | 2.3 KB | Smallest React option |
| Motion LazyMotion | 4.6 KB | Recommended default |
| Motion useAnimate hybrid | 17 KB | Imperative + stagger |
| Motion full | 34 KB | All features |
AutoAnimate Usage
import { useAutoAnimate } from '@formkit/auto-animate/react';
const [parent] = useAutoAnimate();
return (
<ul ref={parent}>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);3 lines of code, zero configuration.
Use-Case Recommendations
E-commerce
- AutoAnimate: Shopping cart items, product filter results, notification toasts
- Motion: Product image carousel (drag), hero parallax, product detail transitions (shared elements)
Blog / Content Site
- AutoAnimate: Article list filtering, comment threads, tag selection
- Motion: Hero parallax, scroll-triggered reveals, image lightbox modals
Dashboard / SaaS
- AutoAnimate: Sidebar accordion, data table row add/remove, toast notifications
- Motion: Kanban drag-to-reorder, chart animations, complex modal transitions
Landing Page / Marketing
- AutoAnimate: FAQ accordion, feature comparison filtering
- Motion: Hero section (parallax, scroll effects), scroll-triggered reveals, interactive demos
Using Both Together
They complement each other well:
const [listRef] = useAutoAnimate();
return (
<>
{/* Motion for hero parallax */}
<motion.div style={{ y: parallaxY }}>Hero section</motion.div>
{/* AutoAnimate for product list */}
<ul ref={listRef}>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
{/* Motion for carousel */}
<motion.div drag="x">
{images.map((img) => (
<img src={img.url} />
))}
</motion.div>
</>
);Combined bundle: 3.28 KB (AutoAnimate) + 4.6 KB (LazyMotion) = ~8 KB total.
Migration
AutoAnimate → Motion
1. Install: pnpm add motion 2. Replace <div ref={parent}> with <motion.div> 3. Add initial, animate, exit props 4. Wrap in <AnimatePresence> for exit animations 5. Add layout prop for reordering
Motion → AutoAnimate
1. Install: pnpm add @formkit/auto-animate 2. Remove Motion props (initial, animate, exit, layout) 3. Remove AnimatePresence wrapper 4. Add useAutoAnimate hook and attach ref to parent 5. Bundle savings: 34 KB → 3.28 KB (~90% reduction)
Next.js Integration
Motion requires React 18.2+. Compatible with both App Router and Pages Router.
App Router
Motion components only work in Client Components. Three patterns ordered by recommendation:
Pattern 1: Wrapper Component (Recommended)
Create a single client component that re-exports Motion:
// src/components/motion-client.tsx
'use client';
import * as motion from 'motion/react-client';
export { motion };
export {
AnimatePresence,
MotionConfig,
LazyMotion,
LayoutGroup,
useMotionValue,
useTransform,
useScroll,
useSpring,
useAnimate,
useInView,
} from 'motion/react-client';// src/app/page.tsx (Server Component)
import { motion } from '@/components/motion-client';
export default function Page() {
return <motion.div animate={{ opacity: 1 }}>Content</motion.div>;
}Use motion/react-client instead of motion/react in App Router -- it excludes server-side code, reducing client JavaScript.
Pattern 2: Direct Client Component
'use client';
import { motion } from 'motion/react-client';
export default function AnimatedSection() {
return <motion.div animate={{ opacity: 1 }}>Content</motion.div>;
}Downside: entire component tree becomes client-rendered.
Pattern 3: Server Data + Client Animation
Keep data fetching on the server, pass to client components for animation:
// src/components/AnimatedCard.tsx
'use client';
import { motion } from 'motion/react-client';
export function AnimatedCard({
product,
index,
}: {
product: Product;
index: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.05 }}
>
<h3>{product.name}</h3>
</motion.div>
);
}// src/app/products/page.tsx (Server Component)
import { AnimatedCard } from '@/components/AnimatedCard';
export default async function ProductsPage() {
const products = await getProducts();
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product, i) => (
<AnimatedCard key={product.id} product={product} index={i} />
))}
</div>
);
}MotionConfig Provider
// src/components/MotionProvider.tsx
'use client';
import { MotionConfig } from 'motion/react-client';
export function MotionProvider({ children }: { children: ReactNode }) {
return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}Add to root layout for global reduced-motion support.
LazyMotion in App Router
// src/components/LazyMotionProvider.tsx
'use client';
import { LazyMotion, domAnimation } from 'motion/react-client';
export function LazyMotionProvider({ children }: { children: ReactNode }) {
return <LazyMotion features={domAnimation}>{children}</LazyMotion>;
}
export { m as motion } from 'motion/react-client';Reduces Motion bundle from 34 KB to 4.6 KB.
Pages Router
Works out of the box. No "use client" needed:
import { motion } from 'motion/react';
export default function Page() {
return <motion.div>No "use client" needed</motion.div>;
}For hydration errors, use dynamic import:
import dynamic from 'next/dynamic';
const AnimatedComponent = dynamic(
() => import('@/components/AnimatedComponent'),
{ ssr: false },
);Page Transitions with template.tsx
Next.js App Router soft navigation does not trigger React unmount, so AnimatePresence exit animations do not fire for route changes. Use template.tsx for page enter animations:
// src/app/template.tsx
'use client';
import { motion } from 'motion/react-client';
export default function Template({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
}template.tsx re-mounts on every navigation (unlike layout.tsx), making it the correct place for page enter animations.
For exit animations, use AnimatePresence at the component level (modals, dropdowns, tooltips) rather than page level.
Known Issues
Reorder Component Incompatibility
Motion's <Reorder> component has issues with Next.js routing (stuck states, items not reordering). Auto-scroll only works inside overflow: auto/scroll containers, not page-level scroll. For complex drag-and-drop (multi-row, cross-column), use @dnd-kit/core instead.
Code Splitting
For animations not needed on initial load:
import dynamic from 'next/dynamic';
const AnimatedHero = dynamic(() => import('@/components/AnimatedHero'), {
ssr: false,
});Deployment Checklist
- All Motion files have
"use client"directive - Using
motion/react-clientimport (notmotion/react) - LazyMotion enabled for bundle size
- MotionConfig with
reducedMotion="user"set up - No Motion usage in Server Components
- AnimatePresence only for component-level animations (not routes)
- Page enter animations use
template.tsx - Tested with
prefers-reduced-motionenabled - Bundle analyzed (target < 5 KB for Motion)
Performance Optimization
Bundle Size Reduction
LazyMotion (Recommended -- 34 KB to 4.6 KB)
import { LazyMotion, domAnimation, m } from 'motion/react';
<LazyMotion features={domAnimation}>
<m.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
Uses "m" instead of "motion"
</m.div>
</LazyMotion>;domAnimation includes transforms, opacity, gestures, layout, and useScroll. For SVG path animations use domMax (~6 KB).
Async Feature Loading
Load features asynchronously for even smaller initial bundle:
const loadFeatures = () =>
import('motion/dom-animation').then((res) => res.default);
<LazyMotion features={loadFeatures} strict>
<m.div animate={{ opacity: 1 }}>Loaded async</m.div>
</LazyMotion>;The strict prop throws an error if motion is used instead of m inside LazyMotion.
useAnimate Mini (Smallest -- 2.3 KB)
import { useAnimate } from 'motion/react';
function Component() {
const [scope, animate] = useAnimate();
useEffect(() => {
animate(scope.current, { opacity: 1, x: 0 });
}, []);
return (
<div ref={scope} style={{ opacity: 0, transform: 'translateX(-20px)' }}>
Content
</div>
);
}useAnimate Hybrid (17 KB)
Includes stagger support for imperative animations:
import { useAnimate, stagger } from 'motion/react';
function StaggeredList() {
const [scope, animate] = useAnimate();
const handleAnimate = () => {
animate('li', { opacity: 1, x: 0 }, { delay: stagger(0.1) });
};
return (
<ul ref={scope}>
{items.map((item) => (
<li key={item.id} style={{ opacity: 0 }}>
{item.text}
</li>
))}
</ul>
);
}Bundle Size Summary
| Approach | Size | Includes |
|---|---|---|
| useAnimate mini | 2.3 KB | Imperative animations only |
| LazyMotion + domAnimation | 4.6 KB | Transforms, opacity, gestures, layout |
| LazyMotion + domMax | ~6 KB | Above + SVG path animations |
| useAnimate hybrid | 17 KB | Imperative + stagger + selectors |
Full motion component | 34 KB | All features |
Hardware Acceleration
GPU-Accelerated Properties
| Animate (GPU-accelerated) | Avoid (triggers layout reflow) |
|---|---|
x, y, scale, rotate | width, height |
opacity | top, left, right, bottom |
filter (blur, brightness) | padding, margin |
clipPath | fontSize |
willChange Hint
Add willChange for frequently animated transforms:
<motion.div
style={{ willChange: 'transform' }}
animate={{ x: 100, rotate: 45 }}
/>Use sparingly -- adding willChange to too many elements wastes GPU memory.
Use layout Prop for Size Changes
Instead of animating width/height directly, use the layout prop:
<motion.div layout>
{isExpanded ? <LargeContent /> : <SmallContent />}
</motion.div>The layout prop uses FLIP (First, Last, Invert, Play) to animate via transforms instead of layout properties.
Large Lists (50+ Items)
Virtualization (Best for 100+ Items)
import { FixedSizeList } from 'react-window';
import { motion } from 'motion/react';
<FixedSizeList height={600} itemCount={1000} itemSize={50}>
{({ index, style }) => (
<motion.div style={style} layout>
Item {index}
</motion.div>
)}
</FixedSizeList>;Only renders visible items, reducing DOM nodes from 1000+ to ~20.
Stagger with delayChildren (10-30 Items)
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.1,
},
},
};whileInView Lazy Animation
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
>
{item.content}
</motion.div>Only animates when scrolled into view. Use once: true to avoid repeated triggers.
Adaptive Simplification (50+ Items)
const useReducedAnimations = items.length > 50;
<motion.div
initial={{ opacity: useReducedAnimations ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: useReducedAnimations ? 0 : 0.3 }}
/>;Performance Comparison (1000 Items)
| Approach | FPS | DOM Nodes |
|---|---|---|
| No optimization | 5-10 fps | 1000+ |
| Stagger only | 15-20 fps | 1000+ |
| whileInView | 40-50 fps | 1000+ |
| Simplified animations | 50-60 fps | 1000+ |
| Virtualization | 60 fps | ~20 |
AnimatePresence Optimization
Use mode="wait" for sequential enter/exit (fewer simultaneous DOM nodes):
<AnimatePresence mode="wait">
{isVisible && <motion.div key="content">Content</motion.div>}
</AnimatePresence>Only wrap components that actually exit -- avoid wrapping entire layouts in AnimatePresence.
Gesture Performance
Disable momentum when not needed (precise positioning, drag-to-reorder):
<motion.div drag dragMomentum={false} dragElastic={0.1} />Default elasticity is 0.5. Use 0.1-0.2 for most cases, 0 for maximum performance.
Transition Types
| Type | Use Case | Performance |
|---|---|---|
| spring | Interactive gestures | More JS calc |
| tween | Simple UI animations | Less JS calc |
Spring animations run more JavaScript per frame but feel more natural. Use tween for simple opacity/position changes where spring physics are not needed.
Duration Guidelines
| Range | Effect |
|---|---|
| < 100ms | Too fast (abrupt) |
| 200-400ms | Sweet spot for most UI |
| > 500ms | Too slow (sluggish) |
Performance Budget
| Metric | Target | Maximum |
|---|---|---|
| Bundle size (Motion) | < 5 KB | 10 KB |
| Frame rate | 60 FPS | 40 FPS |
| Animated elements simultaneous | < 20 | < 50 |
| AnimatePresence wrappers | < 5 | < 10 |
| Layout animations simultaneous | < 10 | < 20 |
Production Checklist
- Bundle optimized (LazyMotion or useAnimate)
willChangeadded for frequently animated transforms- Only GPU-accelerated properties (transform, opacity)
layoutprop instead of animating width/height- Large lists use virtualization (50+ items)
- AnimatePresence only wraps necessary components
layoutScrollon scrollable containers with layout animationslayoutRooton fixed-position elements with layout animationslayoutDependencyset where layout changes are state-driven- Tested on low-end devices (throttle CPU in DevTools)
- Tested with
prefers-reduced-motionenabled - Frame rate verified (60fps target)
Scroll Animations
Motion supports two types of scroll animations: scroll-triggered (animation fires when element enters viewport) and scroll-linked (animation values tied directly to scroll position).
Scroll-Triggered (whileInView)
<motion.div
initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
>
Fades in when 100px from entering viewport
</motion.div>Viewport Options
| Option | Type | Description |
|---|---|---|
once | boolean | Animate only the first time element enters (no re-trigger) |
margin | string | Expand or shrink the viewport detection area |
amount | number | Fraction of element visible to trigger (0 to 1) |
root | ref | Scrollable ancestor to use as viewport |
Staggered Scroll Reveal
const container = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.15 },
},
};
const item = {
hidden: { opacity: 0, y: 30 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
};
<motion.div
variants={container}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
{features.map((feature) => (
<motion.div key={feature.id} variants={item}>
{feature.title}
</motion.div>
))}
</motion.div>;Scroll-Linked (useScroll)
useScroll returns four motion values:
scrollX/scrollY: scroll offset in pixelsscrollXProgress/scrollYProgress: scroll progress between 0 and 1
Page Scroll Progress Indicator
import { motion, useScroll } from 'motion/react';
function ScrollProgress() {
const { scrollYProgress } = useScroll();
return (
<motion.div
style={{ scaleX: scrollYProgress }}
className="fixed top-0 left-0 right-0 h-1 bg-blue-600 origin-left z-50"
/>
);
}Tracking a Scrollable Container
Pass a ref to container to track a specific scrollable element instead of the page:
import { useRef } from 'react';
import { useScroll, useTransform, motion } from 'motion/react';
function ScrollContainer() {
const containerRef = useRef(null);
const { scrollYProgress } = useScroll({ container: containerRef });
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0.3, 1, 0.3]);
return (
<div ref={containerRef} className="h-96 overflow-y-auto">
<motion.div style={{ opacity }}>
Content that fades based on container scroll
</motion.div>
</div>
);
}Tracking an Element in the Viewport
Pass a ref to target to track an element's progress through the viewport:
function ElementProgress() {
const targetRef = useRef(null);
const { scrollYProgress } = useScroll({
target: targetRef,
offset: ['start end', 'end start'],
});
const scale = useTransform(scrollYProgress, [0, 1], [0.8, 1.2]);
return (
<motion.div ref={targetRef} style={{ scale }}>
Scales as it moves through viewport
</motion.div>
);
}Offset Configuration
Offsets define which intersection between target and container maps to progress 0 and 1. Each offset is a string with two keywords: first for the target edge, second for the container edge.
Offset Keywords
| Keyword | Meaning |
|---|---|
start | Top/left edge of element |
end | Bottom/right edge |
center | Center of element |
| Number | 0 = start, 1 = end of axis |
| Pixels | "100px" from start |
| Percent | "50%" same as 0.5 |
vh/vw | Viewport units |
Common Offset Patterns
// Element enters from bottom, leaves at top (full travel)
offset: ['start end', 'end start'];
// Element enters from bottom, stops when fully visible
offset: ['start end', 'end end'];
// Track only while element overlaps viewport center
offset: ['start center', 'end center'];
// Trigger when element top hits 200px from viewport top
offset: ['start 200px', 'end start'];Parallax Effects
Hero Section Parallax
import { useScroll, useTransform, motion } from 'motion/react';
function ParallaxHero() {
const { scrollY } = useScroll();
const bgY = useTransform(scrollY, [0, 500], [0, 150]);
const textY = useTransform(scrollY, [0, 500], [0, -50]);
const opacity = useTransform(scrollY, [0, 300], [1, 0]);
return (
<div className="relative h-screen overflow-hidden">
<motion.div style={{ y: bgY }} className="absolute inset-0">
<img src="/bg.jpg" alt="" className="w-full h-full object-cover" />
</motion.div>
<motion.div
style={{ y: textY, opacity }}
className="relative z-10 flex items-center justify-center h-full"
>
<h1>Parallax Effect</h1>
</motion.div>
</div>
);
}Multi-Layer Parallax
function MultiLayerParallax() {
const { scrollY } = useScroll();
const bgY = useTransform(scrollY, [0, 600], [0, 200]);
const midY = useTransform(scrollY, [0, 600], [0, 100]);
const fgY = useTransform(scrollY, [0, 600], [0, 30]);
return (
<div className="relative h-screen overflow-hidden">
<motion.div style={{ y: bgY }} className="absolute inset-0">
Background layer
</motion.div>
<motion.div style={{ y: midY }} className="absolute inset-0">
Middle layer
</motion.div>
<motion.div style={{ y: fgY }} className="absolute inset-0">
Foreground layer
</motion.div>
</div>
);
}useTransform Patterns
Value Mapping
Map one motion value range to another:
const { scrollYProgress } = useScroll();
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [0.5, 1.5]);
const rotate = useTransform(scrollYProgress, [0, 1], [0, 360]);Transform Function
Use a function for custom transformations. Motion values read via get() are auto-subscribed:
const { scrollY } = useScroll();
const backgroundColor = useTransform(scrollY, (latest) => {
return latest > 100 ? '#1a1a2e' : '#ffffff';
});Chaining Transforms
const { scrollYProgress } = useScroll();
const smoothProgress = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
});
const y = useTransform(smoothProgress, [0, 1], [0, -200]);useSpring wraps a motion value with spring physics, smoothing out jerky scroll input.
Combining useScroll with useAnimate
import { useAnimate, useInView } from 'motion/react';
import { useEffect } from 'react';
function ScrollTriggeredSequence() {
const [scope, animate] = useAnimate();
const isInView = useInView(scope);
useEffect(() => {
if (isInView) {
animate(scope.current, { opacity: 1, y: 0 }, { duration: 0.5 });
}
}, [isInView]);
return (
<div ref={scope} style={{ opacity: 0, transform: 'translateY(20px)' }}>
Triggers animation sequence when scrolled into view
</div>
);
}Performance Notes
- Motion uses the native ScrollTimeline API when available for hardware-accelerated scroll animations
useScrollvalues update on every scroll frame -- avoid expensive computations inuseTransformcallbacks- Use
viewport={{ once: true }}onwhileInViewto avoid repeated animation triggers - For scroll-linked animations affecting many elements, prefer CSS transforms (
x,y,scale,rotate,opacity) over layout-triggering properties
Troubleshooting
Naming Migration: framer-motion to motion
The library was renamed from Framer Motion to Motion in late 2024.
| Old (do not use) | New (use this) |
|---|---|
npm install framer-motion | npm install motion |
from "framer-motion" | from "motion/react" |
import { motion } from "framer-motion" | import { motion } from "motion/react" |
import { useAnimation } from "framer-motion" | import { useAnimation } from "motion/react" |
import { LayoutGroup } from "framer-motion" | import { LayoutGroup } from "motion/react" |
The API is unchanged -- only the package name and import path changed. All variants, hooks, and components work the same way. No breaking changes in Motion v12.
AnimatePresence Exit Not Playing
AnimatePresence must stay mounted. The condition goes inside, not outside:
// Wrong -- AnimatePresence unmounts with condition
{
isVisible && (
<AnimatePresence>
<motion.div>...</motion.div>
</AnimatePresence>
);
}
// Correct -- AnimatePresence stays mounted
<AnimatePresence>
{isVisible && <motion.div key="unique">...</motion.div>}
</AnimatePresence>;Every direct child of AnimatePresence must have a unique key prop.
AnimatePresence Exit Gets Stuck
When a child component inside AnimatePresence unmounts immediately after exit triggers, the exit state can get stuck. Only use conditional rendering on direct AnimatePresence children, not on nested motion components.
Exit Props on Staggered Modal Children
Exit animations on staggered children inside modals can prevent the modal from unmounting (backdrop remains visible). Remove exit from children or set instant duration:
// Wrong -- staggered children with exit prevent modal removal
<motion.li
key={item.id}
exit={{ opacity: 1, scale: 1 }}
>
{item.content}
</motion.li>
// Correct -- instant exit or no exit on children
<motion.li
key={item.id}
exit={{ opacity: 0, transition: { duration: 0 } }}
>
{item.content}
</motion.li>Tailwind Transitions Conflict
Remove transition-*, duration-* classes from elements using Motion animate props:
// Wrong -- stuttering animations
<motion.div className="transition-all" animate={{ x: 100 }} />
// Correct
<motion.div animate={{ x: 100 }} />Motion uses inline styles or native browser animations. Tailwind CSS transitions interfere with Motion's animation system.
Next.js "use client" Missing
Motion components need "use client" directive in App Router. Import from motion/react-client for optimized client bundles.
Error: Error: motion is not defined
Scrollable Container Layout Glitches
Add layoutScroll prop to the scrollable parent so layout animations account for scroll offset:
<motion.div layoutScroll className="overflow-auto">
{items.map((item) => (
<motion.div key={item.id} layout>
{item.content}
</motion.div>
))}
</motion.div>Fixed Position Layout Animations
Add layoutRoot to the fixed container so layout animations account for page scroll:
<motion.div layoutRoot className="fixed top-0 left-0">
<motion.div layout>Content</motion.div>
</motion.div>Layout Animations in Scaled Containers
Layout animations use pixel coordinates, but parent scale transforms affect visual coordinates. Use transformTemplate to correct:
const scale = 2;
<div style={{ transform: `scale(${scale})` }}>
<motion.div
layout
transformTemplate={(_, generated) => {
const match = /translate3d\((.+)px,\s?(.+)px,\s?(.+)px\)/.exec(generated);
if (match) {
const [, x, y, z] = match;
return `translate3d(${Number(x) / scale}px, ${Number(y) / scale}px, ${Number(z) / scale}px)`;
}
return generated;
}}
>
Content
</motion.div>
</div>;Limitation: only corrects layout animations, requires knowing the parent scale value.
Reorder Component Limitations
Reorder auto-scroll only works inside overflow: auto/scroll containers, not page-level scroll:
// Wrong -- page-level scrolling (auto-scroll fails)
<body style={{ height: "200vh" }}>
<Reorder.Group values={items} onReorder={setItems}>
{/* Auto-scroll does not trigger at viewport edges */}
</Reorder.Group>
</body>
// Correct -- container with overflow
<div style={{ height: "300px", overflow: "auto" }}>
<Reorder.Group values={items} onReorder={setItems}>
{items.map((item) => (
<Reorder.Item key={item.id} value={item}>
{item.content}
</Reorder.Item>
))}
</Reorder.Group>
</div>For complex drag-and-drop (multi-row, cross-column, page-level scroll), use @dnd-kit/core + @dnd-kit/sortable.
React 19 StrictMode Drag Bug
Top-to-bottom drag breaks with React 19 + StrictMode + some component libraries (e.g., Ant Design). Dragged element position appears offset. Does not occur in React 18 or React 19 without StrictMode. Bottom-to-top drag works fine. Temporarily disable StrictMode for drag-heavy features if affected.
popLayout Sub-Pixel Shift
AnimatePresence mode="popLayout" rounds sub-pixel values from getBoundingClientRect, causing a visible 1px shift before exit. Can cause text wrapping changes. Use whole pixel values for dimensions or avoid popLayout for precision-sensitive layouts.
Percentage Values in Flex Containers
Percentage-based x or y values in initial with flex containers using justify-content: center can cause layout animations to teleport instead of animating. Convert to pixel values by calculating container width.
layoutId + AnimatePresence
When using layoutId inside AnimatePresence, wrap in <LayoutGroup> to prevent unmount failures:
import { LayoutGroup, AnimatePresence } from 'motion/react';
<LayoutGroup>
<AnimatePresence>
{items.map((item) => (
<motion.div key={item.id} layoutId={item.id}>
{item.content}
</motion.div>
))}
</AnimatePresence>
</LayoutGroup>;Soft Navigation Breaks Exit Animations
Next.js App Router soft navigation does not trigger React unmount, so AnimatePresence exit animations do not fire for page transitions. Use AnimatePresence for component-level animations only (modals, dropdowns). For page enter animations, use template.tsx.