A practical guide to building accessible, performant animations with Framer Motion. Covers when to animate, what to animate, performance budgets, accessibility requirements, and debugging techniques.
| Situation | Why | Pattern |
|---|---|---|
| Content enters the viewport | Draws attention to new content | ScrollReveal with fadeUp |
| User navigates between pages | Provides spatial continuity | PageTransition with slide |
| Data loads asynchronously | Reduces perceived wait time | SkeletonLoader → ContentSwap |
| User interacts with an element | Confirms the interaction was received | whileHover + whileTap |
| A value changes | Shows the change, not just the result | AnimatedCounter |
| Items are added/removed | Prevents jarring layout shifts | AnimatePresence + exit variants |
| State toggles | Shows the transition between states | layout animation |
| Situation | Why |
|---|---|
| The user is typing | Animations near text input are distracting |
| Form validation errors appear | Users need to read errors, not watch them bounce |
| Data is already visible | Re-animating visible content feels like lag |
| Multiple elements need attention | Too many simultaneous animations cause chaos |
| The action is destructive | Delete confirmations should feel deliberate, not flashy |
| Network requests are in-flight | Don't animate loading states that may flash briefly |
If an animation takes longer than 3 seconds to complete (including stagger), it's too long. Users lose patience after 3 seconds. The ideal animation:
Is the element entering the viewport for the first time?
├── YES → Is it triggered by scroll?
│ ├── YES → ScrollReveal component
│ └── NO → Entrance variant (fadeUp, scaleUp, etc.)
└── NO → Is the element responding to user interaction?
├── YES → Is it a hover/tap?
│ ├── YES → Hover variant (lift, press, grow, etc.)
│ └── NO → useMicroInteraction hook
└── NO → Is the element leaving?
├── YES → Exit variant + AnimatePresence
└── NO → Is it a layout change?
├── YES → layout prop + LayoutGroup
└── NO → You probably don't need animation here
| Context | Recommended Pattern | Avoid |
|---|---|---|
| Marketing landing page | ScrollReveal, Parallax, StaggerList | Over-animating — let content breathe |
| Dashboard / data app | PageTransition, AnimatedCounter, SkeletonLoader | Parallax (too playful for data UI) |
| E-commerce | fadeUp on products, lift on cards, ContentSwap | Slow transitions (users are goal-oriented) |
| Blog / documentation | ScrollReveal (subtle), fadeUp only | Stagger (text should appear all at once) |
| Mobile app | Reduced distances, faster springs, press over lift | Parallax (scroll jank risk on mobile) |
| Portfolio / creative | blurIn, clipReveal, morph page transitions | Conservative patterns (this is where you show off) |
At 60fps, each frame has 16.67ms. Your animation logic must complete within this budget:
16ms total frame budget
├── 2-4ms JavaScript (animation calculations)
├── 2-4ms Style recalculation
├── 2-4ms Layout (if properties change layout)
├── 2-4ms Paint (if properties need repainting)
└── 2-4ms Compositing (GPU layer management)
| Tier | Properties | Cost | GPU Composited? |
|---|---|---|---|
| Free | transform (translate, scale, rotate) | Composite only | Yes |
| Free | opacity | Composite only | Yes |
| Cheap | filter (blur, brightness) | Paint + Composite | Partially |
| Expensive | box-shadow | Paint + Composite | No |
| Very Expensive | width, height, padding, margin | Layout + Paint + Composite | No |
| Catastrophic | top, left, right, bottom | Layout + Paint + Composite | No |
1. Only animate transform and opacity for the smoothest results
2. Use will-change: transform to hint the browser (the variants in this pack do this automatically)
3. Limit will-change to 3-5 elements — too many promoted layers waste GPU memory
4. Avoid animating during scroll unless using useScroll() (passive, RAF-based)
5. Test on low-end devices — your MacBook Pro is not representative
// In Chrome DevTools:
// 1. Open Performance panel
// 2. Enable "Screenshots" and "Web Vitals"
// 3. Click Record, trigger your animation, stop
// 4. Look for:
// - Long frames (red bars above 16ms)
// - Layout shifts (yellow "Layout" blocks)
// - Paint events (green blocks — should be rare during animation)
// Programmatically:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 16) {
console.warn(`Long frame: ${entry.duration.toFixed(1)}ms`, entry);
}
}
});
observer.observe({ type: 'longtask', buffered: true });This is not optional. Approximately 25-35% of users have motion sensitivity to some degree. Vestibular disorders can cause nausea, dizziness, and migraines from screen motion.
Every animation in this pack respects prefers-reduced-motion: reduce automatically. Here's what happens:
| Full Motion | Reduced Motion |
|---|---|
| Slide + fade entrance | Opacity-only fade (200ms) |
| Parallax scroll effects | Static content (no movement) |
| Staggered list animation | Quick sequential opacity fade |
| Spring-based hover | Opacity change (no transform) |
| Page slide transition | Crossfade only |
| Animated counter | Shows final value immediately |
| Shimmer skeleton | Slow pulse (no directional movement) |
macOS: System Settings → Accessibility → Display → Reduce motion
Windows: Settings → Ease of Access → Display → Show animations → Off
Chrome DevTools: Rendering → Emulate CSS media feature → prefers-reduced-motion: reduce
The ReducedMotionProvider in this pack satisfies all three criteria when wrapped around your app root.
Springs use physics simulation (stiffness, damping, mass) instead of fixed duration + easing curve.
Use springs for:
Why: Springs handle interruption gracefully. If a spring animation is in-flight and you change the target, it smoothly redirects. Tweens jump to the start of the new animation.
Tweens use fixed duration + easing curve (like CSS transitions).
Use tweens for:
| Feel | Stiffness | Damping | Mass | Duration (approx) |
|---|---|---|---|---|
| Snappy (buttons) | 400 | 30 | 1.0 | ~200ms |
| Bouncy (playful) | 400 | 15 | 0.8 | ~400ms |
| Gentle (pages) | 120 | 20 | 1.2 | ~600ms |
| Stiff (instant) | 600 | 35 | 0.5 | ~100ms |
| Heavy (dramatic) | 100 | 25 | 2.0 | ~800ms |
| Wobbly (fun) | 350 | 10 | 0.8 | ~600ms |
Use springFromBehavior() from easing.ts to calculate these from desired duration and bounce.
// BAD: Animating width causes layout thrashing
<motion.div animate={{ width: isOpen ? 300 : 0 }} />
// GOOD: Animate scaleX instead (GPU composited)
<motion.div animate={{ scaleX: isOpen ? 1 : 0 }} style={{ transformOrigin: 'left' }} />
// GOOD: If you must animate width, use layout animation
<motion.div layout style={{ width: isOpen ? 300 : 0 }} />// BAD: Every card animates independently on mount
{cards.map(card => (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{card.title}
</motion.div>
))}
// GOOD: Use stagger so they animate sequentially
<StaggerList items={cards} staggerDelay={0.06} renderItem={...} />// BAD: Element unmounts instantly — exit animation never plays
{isVisible && <motion.div exit={{ opacity: 0 }}>Content</motion.div>}
// GOOD: AnimatePresence keeps element mounted during exit animation
<AnimatePresence>
{isVisible && (
<motion.div key="content" exit={{ opacity: 0 }}>Content</motion.div>
)}
</AnimatePresence>// BAD: No key — AnimatePresence can't track which element is leaving
<AnimatePresence>
{items.map(item => <motion.div>{item.name}</motion.div>)}
</AnimatePresence>
// GOOD: Unique key lets AnimatePresence track each element
<AnimatePresence>
{items.map(item => (
<motion.div key={item.id}>{item.name}</motion.div>
))}
</AnimatePresence>// BAD: No reduced motion consideration
<motion.div animate={{ x: 100, rotate: 360 }} />
// GOOD: Use variants from this pack — they include reduced fallbacks
import { fadeUp } from './variants/entrance';
const prefersReduced = useReducedMotion();
<motion.div {...(prefersReduced ? fadeUp.reduced : fadeUp)} />1. Slow down animations: Rendering → "Animation slow-down" slider
2. See layer composition: Layers panel → shows which elements are GPU-composited
3. Detect layout shifts: Performance panel → look for yellow "Layout" events during animation
4. Monitor paint: Rendering → "Paint flashing" → green highlights show repaints
// Log animation lifecycle
<motion.div
onAnimationStart={() => console.log('Animation started')}
onAnimationComplete={() => console.log('Animation completed')}
animate={{ opacity: 1 }}
/>
// Inspect motion values
import { useMotionValue, useTransform } from 'framer-motion';
const x = useMotionValue(0);
x.on('change', (latest) => console.log('x:', latest));| Symptom | Cause | Fix |
|---|---|---|
| Animation stutters on first run | Browser creating new GPU layer | Add will-change: transform to the element's idle state |
| Exit animation doesn't play | Missing AnimatePresence wrapper | Wrap conditional rendering in |
| Stagger feels sluggish | Too many items with default delay | Use safeStaggerDelay() to cap total time |
| Spring oscillates too long | Damping too low | Increase damping or use restDelta option |
| Animation replays on every render | initial prop without key | Add stable key prop or use initial={false} |
| Layout shift when element appears | Element has fixed dimensions | Use layout prop for smooth size transitions |
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| CSS transforms | 36+ | 16+ | 9+ | 12+ |
| Intersection Observer | 51+ | 55+ | 12.1+ | 15+ |
| prefers-reduced-motion | 74+ | 63+ | 10.1+ | 79+ |
| Web Animations API | 36+ | 48+ | 13.1+ | 79+ |
| will-change | 36+ | 36+ | 9.1+ | 79+ |
| filter (blur, etc.) | 53+ | 35+ | 9.1+ | 79+ |
| clip-path (inset) | 55+ | 54+ | 13.1+ | 79+ |
All features used in this pack are supported in all modern browsers (released after 2020).
For IE11 support (not recommended), you would need: