UI Animation
Tasteful UI animation with proper timing, accessibility, and performance.
Quick Start
Technique Decision:
- Simple transition? → CSS/Tailwind (, )
- Enter/exit with unmount? → Motion +
- Gesture-driven? → Motion springs
- Layout changes? → Motion prop
Default: Start with Easing Decision Tree → Duration Guidelines → Implement → Add a11y → Verify
Core Principles
- Natural Motion: Mimic physics. Avoid linear easing - nothing moves at constant speed.
- Purposeful: Every animation must add meaning. If you can't explain its benefit, remove it.
- Fast: UI animations under 300ms. Hover effects under 150ms. Over 500ms feels sluggish.
- Interruptible: Use springs for gesture-driven animations - they handle interruption gracefully.
- Accessible: Always respect . Non-negotiable.
Workflow
Step 1: Classify Animation Type
| Type | Examples | Technique |
|---|
| Micro-interaction | Button press, toggle, checkbox | CSS/Tailwind |
| Enter/Exit | Modal, toast, dropdown | Motion + AnimatePresence |
| Layout change | Accordion, reorder, expand | Motion prop |
| Shared element | Tab indicator, card expand | Motion |
| Gesture | Drag, swipe, pull-to-refresh | Motion springs |
Step 2: Choose Timing
- Use Easing Decision Tree below to select curve
- Use Duration Guidelines to select timing
- For gestures, use Spring Animations config
Step 3: Implement
- Check for copy-paste patterns
- Apply timing from Step 2
- Wrap unmounting elements in
Use
when starting from a design screenshot or mockup. Use
to compare expected vs implemented UI.
Step 4: Accessibility (Required)
- Check with or
- Simplify to opacity-only for reduced motion users
- Verify focus timing (move focus AFTER animation starts)
Step 5: Verify
Easing
Decision Tree
text
What triggers the animation?
│
├─ User action (click, tap, open)?
│ └─ Use: ease-out (fast start, slow end = responsive)
│
├─ Element moving on-screen (tab switch, reorder)?
│ └─ Use: ease-in-out (accelerate then decelerate)
│
├─ Continuous/looping (spinner, marquee)?
│ └─ Use: linear (constant speed appropriate here)
│
├─ Gesture-based (drag, swipe, pull)?
│ └─ Use: Spring animation (physics-based, interruptible)
│
└─ Hover/focus effect?
└─ Use: CSS ease, 150ms (subtle, immediate)
Quick Reference
| Purpose | CSS | Tailwind | Duration |
|---|
| Modal/drawer enter | cubic-bezier(0.32, 0.72, 0, 1)
| | 200ms |
| Modal/drawer exit | cubic-bezier(0.32, 0.72, 0, 1)
| | 150ms |
| On-screen movement | cubic-bezier(0.4, 0, 0.2, 1)
| | 200-300ms |
| Hover effect | | | 150ms |
| Button press | — | | instant |
Pro Curves
| Name | Value | Use Case |
|---|
| Vaul (buttery) | cubic-bezier(0.32, 0.72, 0, 1)
| Sheets, drawers, modals |
| Emphasized | cubic-bezier(0.2, 0, 0, 1)
| Material Design 3 |
| Snappy | cubic-bezier(0.25, 1, 0.5, 1)
| Fast UI transitions |
Avoid: Built-in
—starts slow, feels sluggish.
Duration Guidelines
| Type | Duration | Notes |
|---|
| Micro-feedback | 100-150ms | Button press, toggle, checkbox |
| Small transition | 150-250ms | Tooltip, icon morph |
| Medium transition | 200-300ms | Modal, popover, dropdown |
| Large transition | 300-400ms | Page transition, complex layout |
| Maximum | <500ms | Exceptions: onboarding, data viz |
Key Rules:
- Exit faster than enter: 200ms enter → 150ms exit
- Hover = fast: Under 150ms
- High-frequency = instant: Keyboard nav, scrolling—<100ms or none
Spring Animations
Duration-based (Recommended)
Easier to compose with other timed animations. Use
(time to visually reach target) and
(0 = no bounce, 1 = very bouncy).
| Feel | Config | Use Case |
|---|
| Snappy | { duration: 0.3, bounce: 0.15 }
| Tabs, buttons, quick feedback |
| Standard | { duration: 0.4, bounce: 0.2 }
| Modals, menus, general UI |
| Gentle | { duration: 0.5, bounce: 0.25 }
| Smooth, human-like flow |
Physics-based (Legacy/Advanced)
Use when integrating with physics libraries or when precise control over spring dynamics is needed.
| Feel | Config | Use Case |
|---|
| Snappy | { stiffness: 400, damping: 30 }
| High-frequency interactions |
| Standard | { stiffness: 300, damping: 20 }
| Framer Handshake convention |
| Gentle | { stiffness: 120, damping: 14 }
| react-motion preset |
Gotcha:
/
/
overrides
/
. Pick one approach—don't mix.
Layout Animations
The Prop
Add
to animate position/size changes automatically. Use
for text (prevents distortion).
| Prop Value | Effect | Use Case |
|---|
| Animates position AND size | Default for flexible elements |
| Animates only translation | Text/icons that shouldn't stretch |
| Animates only dimensions | Fixed-position expanding panels |
Shared Element Transitions ()
Elements with matching
animate between each other when entering/exiting.
Critical Trap: Duplicate
values cause elements to
teleport across the page. Use unique IDs per context or wrap in
.
Layout Gotchas
- Text distortion: Apply to text elements
- Border radius: Can warp during scale—Motion auto-corrects, but test it
- SVG elements: doesn't work on —use manual morphing
Gesture Gotchas
| Problem | Solution |
|---|
| Touch scroll conflicts | |
| Element snaps back | Check + |
| Momentum feels wrong | for precise UIs |
| One-direction only | dragElastic={{ top: 0, bottom: 0.5 }}
|
Swipe dismiss: Check BOTH distance AND velocity—users expect flicks to work.
Accessibility
prefers-reduced-motion (REQUIRED)
tsx
import { useReducedMotion } from "motion/react"
const shouldReduce = useReducedMotion()
const variants = shouldReduce
? { opacity: 1 } // Fade only
: { opacity: 1, scale: 1, y: 0 } // Full animation
Tailwind:
motion-safe:animate-pulse
/
motion-reduce:transition-none
Best practice: Don't disable—simplify. Remove spatial movement, keep opacity.
Focus Management
- Move focus AFTER animation starts:
requestAnimationFrame(() => ref.focus())
- Restore focus to trigger on close
- Don't animate inside regions
Touch Targets
| Standard | Size | Tailwind | Physical |
|---|
| Material Design | 48×48 dp | | ~9mm (recommended) |
| Apple HIG | 44×44 pt | | ~7mm |
| WCAG 2.2 (AA) | 24×24 px | | ~5mm (minimum) |
Why? Average adult finger pad is ~9mm. Targets below 7mm cause "fat finger" errors. Use Material's 48dp for cross-platform; Apple's 44pt is iOS-specific minimum.
Performance
Golden Rules
- Only animate and —GPU-accelerated
- Never animate: , , , , ,
- sparingly—only during animation, remove after
- Blur thresholds:
- ≤10px: Safe for animation
- 11-20px: May cause jank on mobile/4K—test thoroughly
-
20px: Avoid for real-time effects; use pre-blurred images instead
- Prefer CSS over JS for simple transitions
Key Traps
- Height animation: Use prop, not
- Invisible but clickable: still receives clicks—add
- will-change everywhere: Causes layer explosion, mobile crashes
See
for detailed examples.
Examples
Copy-paste patterns organized by category in
:
- Common UI Patterns: Button press, modal enter/exit, error shake, staggered lists, accordion
- Touch & Interaction: Accessible touch targets, hover on touch devices, instant tooltips
- Layout Animations: prop, shared elements, collision fixes
- Radix UI Integration: pattern, , origin-aware popovers
- Accessibility: Focus timing, focus restoration, reduced motion variants
- Performance: Height animation (use ), invisible-but-clickable fix,
- Exit Patterns: with , SSR hydration ()
- Gestures: Swipe dismiss with velocity check, elastic drag boundaries
AnimatePresence
| Mode | Behavior | Use Case |
|---|
| (default) | Simultaneous enter/exit | Crossfades, overlays |
| Exit completes before enter | Page transitions, tabs |
| Exiting elements leave flow | List removals (with ) |
Exit Animation Trap
Exit animations require
—without it, unmount is instant:
tsx
// ❌ Exit never runs
{isOpen && <motion.div exit={{ opacity: 0 }}>...</motion.div>}
// ✅ Wrap in AnimatePresence
<AnimatePresence>
{isOpen && <motion.div exit={{ opacity: 0 }}>...</motion.div>}
</AnimatePresence>
SSR: Use
<AnimatePresence initial={false}>
to prevent animation on page load.
Anti-patterns
| Don't | Do Instead | Why |
|---|
| start | or higher | Avoids "popping" effect |
| for UI | or springs | Linear feels robotic |
| Animations >500ms | Keep under 300ms | Feels sluggish |
| Same tooltip delay | First: 400ms, subsequent: 0ms | User mental model |
| Skip reduced-motion | Always | Accessibility |
| Animate layout props | Use | Performance |
| Excessive bounce | | Unprofessional |
tailwindcss-animate
Tailwind v4: Define keyframes via
in CSS, not config.
| Category | Classes |
|---|
| Enter | animate-in fade-in zoom-in-95 slide-in-from-top
|
| Exit | animate-out fade-out zoom-out-95 slide-out-to-top
|
| Timing | |
| Fill Mode | fill-mode-forwards fill-mode-backwards
|
Integration with Other Skills
| When | Skill | Why |
|---|
| After implementing | | Ensure code passes checks |
| Reusable patterns | | Document component API |
| Before committing | | Use or |
| Integration issues | | Look up latest patterns |
Output
- Artifacts: Code changes only (no outputs)
- Modifications: Component animations, CSS/Tailwind styles, Motion configs
- Type: Workflow skill (guidance only, no scripts)
References
Internal
- - Copy-paste patterns, integration examples, detailed traps
External