| name | motion-animation |
| description | Motion and animation patterns for web: CSS transitions for simple interactions, Framer Motion for complex orchestrated animations, meaningful vs. decorative motion, prefers-reduced-motion (WCAG 2.3), page transitions, and layout animations. Motion should communicate, not decorate. |
Motion & Animation Skill
When to Activate
- Adding hover, focus, or click feedback to interactive elements
- Animating content entering or leaving the DOM (modals, toasts, dropdowns)
- Page transitions or route changes
- List items reordering or filtering
- Designing loading and progress animations
- Ensuring animations respect
prefers-reduced-motion
- Auditing existing UI animations that feel slow or jarring to calibrate duration and easing curves
- Choosing between CSS transitions and Framer Motion for a given interaction complexity level
Principle: Motion Should Communicate
Good motion:
- Confirms that an action was taken (button press feedback)
- Shows where something came from or is going (slide-in from right = came from right)
- Reveals hierarchy (parent expands to reveal children)
- Reduces cognitive load (layout animation shows what moved, not just where it ended up)
Bad motion:
- Decorative spinning, pulsing, or bouncing with no meaning
- Animations that delay getting to content
- Motion that conflicts with system accessibility settings
Layer 1: CSS Transitions (default for most interactions)
Use CSS transitions for: hover states, focus rings, color changes, simple show/hide.
.transition-colors { transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; }
.transition-opacity { transition: opacity 150ms ease; }
.transition-transform { transition: transform 200ms ease; }
.transition-all { transition: all 150ms ease; }
:root {
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
<button className="
bg-brand text-white
transition-colors duration-150
hover:bg-brand-hover
focus-visible:ring-2 focus-visible:ring-brand
active:scale-95 transition-transform duration-75
">
Click me
</button>
<div className={cn(
'overflow-hidden transition-all duration-300 ease-in-out',
open ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
)}>
{children}
</div>
Layer 2: Framer Motion (orchestrated, physics-based)
Use Framer Motion for: enter/exit animations, layout animations, complex sequences, drag.
npm install framer-motion
Enter/Exit Animations
import { motion, AnimatePresence } from 'framer-motion';
const fadeIn = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.15 },
};
const slideUp = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 4 },
transition: { duration: 0.2, ease: [0, 0, 0.2, 1] },
};
const slideInFromRight = {
initial: { opacity: 0, x: 24 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: },
: { : , : [, , , ] },
};
scaleIn = {
: { : , : },
: { : , : },
: { : , : },
: { : },
};
() {
(
);
}
() {
(
)}
</>
);
}
Layout Animations (items reordering)
function SortableList({ items }: { items: Item[] }) {
return (
<ul>
<AnimatePresence initial={false}>
{items.map(item => (
<motion.li
key={item.id}
layout // Automatically animates position changes
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0, 0, 0.2, 1] }}
>
<ListItem item={item} />
</motion.li>
))}
</AnimatePresence>
</>
);
}
Stagger (items appearing in sequence)
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const item = {
hidden: { opacity: 0, y: 8 },
show: { opacity: 1, y: 0 },
};
function ProductGrid({ products }: { products: Product[] }) {
return (
<motion.ul variants={container} initial="hidden" animate="show" className="grid grid-cols-3 gap-4">
{products.map(product => (
<motion.li key={product.id} variants={item}>
<ProductCard product={product} />
</motion.li>
))}
</motion.ul>
);
}
prefers-reduced-motion (Required for WCAG 2.3)
Users who set "Reduce motion" in their OS must see no non-essential animation.
import { useReducedMotion } from 'framer-motion';
function AnimatedCard({ children }: { children: React.ReactNode }) {
const prefersReduced = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: prefersReduced ? 0 : 16 }} // No y movement if reduced
animate={{ opacity: 1, y: 0 }}
transition={{ duration: prefersReduced ? 0 : 0.2 }} // Instant if reduced
>
{children}
</motion.div>
);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 01ms !important;
scroll-: auto !important;
}
}
Page Transitions (Next.js App Router)
'use client';
import { motion } from 'framer-motion';
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0, 0, 0.2, 1] }}
>
{children}
</motion.div>
);
}
Loading Animations
function NProgress() {
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!visible) return;
const interval = setInterval(() => {
setProgress(p => p + (90 - p) * 0.1);
}, 100);
return () => clearInterval(interval);
}, [visible]);
return (
<AnimatePresence>
{visible && (
<motion.div
className="fixed top-0 left-0 h-0.5 bg-brand z-50"
style={{ width: `${progress}%` }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
);
}
function TypingIndicator() {
(
);
}
Duration Reference
| Interaction | Duration | Easing |
|---|
| Hover color/bg change | 100-150ms | ease |
| Button press feedback | 75-100ms | ease-in |
| Tooltip appear | 150ms | ease-out |
| Dropdown/menu open | 150-200ms | ease-out |
| Modal open | 200-250ms | ease-out |
| Page transition | 200-250ms | ease-in-out |
| Notification slide-in | 250-300ms | spring |
| List reorder (layout) | 200ms | ease-in-out |
| Exit animations | 50-75% of enter duration | ease-in |
Rule: If it feels slow, it probably is. Default to shorter durations.
Checklist