UI Animation That Doesn't Suck: A Framer Motion Field Guide
There are two kinds of animated UIs. The first feels polished — elements appear smoothly, transitions guide your eye, interactions feel responsive. The second feels like a theme park — everything bounces, spins, and delays your task.
The difference isn't how much animation. It's knowing which patterns serve the user and which serve the developer's ego.
The Three Useful Animations
Most production UIs need only three types of animation:
1. Entrance Transitions
Elements appearing on the page — mounting a modal, loading a list, navigating to a new page.
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>
Keep it subtle: y: 8 (not 40), duration: 0.2 (not 0.8). The user should feel the transition without consciously noticing it.
The ease curve [0.16, 1, 0.3, 1] is a fast-start, gentle-landing curve. Elements arrive quickly and settle naturally. This is better than ease-in-out for entrances because it doesn't feel sluggish at the start.
2. Exit Transitions
React removes components instantly. Without AnimatePresence, a modal just vanishes. With it, the modal fades out before unmounting:
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<ModalContent />
</motion.div>
)}
</AnimatePresence>
Exit animations should be faster than entrances. Users are moving to the next task — don't make them wait for your animation to finish.
3. Micro-Interactions
Hover and press feedback on interactive elements:
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
Save changes
</motion.button>
1-3% scale change. Any larger and it feels cartoonish. The spring physics give it a subtle bounce that pure CSS transitions can't match.
Staggered Lists
When rendering a list of items, stagger their entrance. Each item appears slightly after the previous one, creating a cascade:
const container = {
hidden: {},
show: { transition: { staggerChildren: 0.04 } },
}
const item = {
hidden: { opacity: 0, y: 6 },
show: { opacity: 1, y: 0 },
}
<motion.ul variants={container} initial="hidden" animate="show">
{items.map((i) => (
<motion.li key={i.id} variants={item}>{i.name}</motion.li>
))}
</motion.ul>
staggerChildren: 0.04 means 40ms between each item. Fast enough to feel coordinated, slow enough to perceive the cascade. For lists longer than 10 items, stagger only the first 10 — after that, show the rest immediately.
Spring Physics vs Easing Curves
CSS transitions use easing curves (cubic bezier). Framer Motion also supports spring physics.
Springs feel more natural for interactive feedback because they don't have a fixed duration — they settle based on stiffness and damping, which mirrors how physical objects move.
Good defaults:
| Interaction | Stiffness | Damping |
|---|---|---|
| Button press | 400 | 17 |
| Modal entrance | 300 | 25 |
| Bouncy reveal | 200 | 12 |
| Gentle settle | 150 | 20 |
Use easing curves for simple opacity/position transitions. Use springs for interactive feedback and playful reveals.
Performance
Only Animate Composite Properties
// ✅ GPU-accelerated — smooth
animate={{ opacity: 0.5, x: 100, scale: 1.1 }}
// ❌ Triggers layout recalculation — janky
animate={{ width: "200px", height: "300px" }}
transform (x, y, scale, rotate) and opacity are composited by the GPU. width, height, margin, and padding trigger layout recalculation on every frame — visible jank on lower-end devices.
Respect Reduced Motion
const shouldReduce = useReducedMotion()
<motion.div
initial={shouldReduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
/>
When prefers-reduced-motion is enabled, skip the initial state. The element renders immediately with no animation. This isn't optional — it's an accessibility requirement.
Lazy-Load Heavy Animations
If a component uses complex layout animations or drag gestures, lazy-load it:
const AnimatedChart = dynamic(
() => import("./animated-chart"),
{ loading: () => <Skeleton className="h-64" /> }
)
The page renders instantly. The animated component loads in the background.
Duration Tokens
Don't use arbitrary timing values. Define a small set and stick to it:
| Token | Value | Use |
|---|---|---|
| Fast | 150ms | Hover, focus, color transitions |
| Normal | 200-250ms | Enter/exit, slide, fade |
| Moderate | 400ms | Complex multi-element transitions |
| Slow | 600ms | Page-level transitions, hero reveals |
Every animation in your app should use one of these four values. Consistency in timing creates a cohesive feel — the same way a consistent color palette creates visual harmony.
The Rule
If the user would still complete their task without the animation, it's a good animation — it enhances without obstructing. If the animation stands between the user and their goal, cut it.