Guide tasteful UI animation with easing, springs, layout animations, gestures, and accessibility. Covers Tailwind and Motion patterns...
Tasteful UI animation with proper timing, accessibility, and performance.
Technique Decision:
transition-*, animate-*)AnimatePresencelayout propDefault: Start with Easing Decision Tree → Duration Guidelines → Implement → Add a11y → Verify
prefers-reduced-motion. Non-negotiable.| Type | Examples | Technique |
|---|---|---|
| Micro-interaction | Button press, toggle, checkbox | CSS/Tailwind |
| Enter/Exit | Modal, toast, dropdown | Motion + AnimatePresence |
| Layout change | Accordion, reorder, expand | Motion layout prop |
| Shared element | Tab indicator, card expand | Motion layoutId |
| Gesture | Drag, swipe, pull-to-refresh | Motion springs |
references/recipes.md for copy-paste patternsAnimatePresenceUse ui_to_artifact when starting from a design screenshot or mockup. Use ui_diff_check to compare expected vs implemented UI.
prefers-reduced-motion with useReducedMotion() or motion-safe:opacity: 0 elements have pointerEvents: nonetransform and opacityWhat 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)
| Purpose | CSS | Tailwind | Duration |
|---|---|---|---|
| Modal/drawer enter | cubic-bezier(0.32, 0.72, 0, 1) |
ease-out duration-200 |
200ms |
| Modal/drawer exit | cubic-bezier(0.32, 0.72, 0, 1) |
ease-out duration-150 |
150ms |
| On-screen movement | cubic-bezier(0.4, 0, 0.2, 1) |
ease-in-out duration-200 |
200-300ms |
| Hover effect | ease |
ease duration-150 |
150ms |
| Button press | — | active:scale-[0.97] |
instant |
| 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 ease-in—starts slow, feels sluggish.
| 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:
Easier to compose with other timed animations. Use visualDuration (time to visually reach target) and bounce (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 |
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:
stiffness/damping/massoverridesduration/bounce. Pick one approach—don't mix.
layout PropAdd layout to animate position/size changes automatically. Use layout="position" for text (prevents distortion).
| Prop Value | Effect | Use Case |
|---|---|---|
layout={true} |
Animates position AND size | Default for flexible elements |
layout="position" |
Animates only translation | Text/icons that shouldn't stretch |
layout="size" |
Animates only dimensions | Fixed-position expanding panels |
layoutId)Elements with matching layoutId animate between each other when entering/exiting.
Critical Trap: Duplicate layoutId values cause elements to teleport across the page. Use unique IDs per context or wrap in <LayoutGroup id="...">.
layout="position" to text elementslayout doesn't work on <path>—use manual morphing| Problem | Solution |
|---|---|
| Touch scroll conflicts | dragPropagation={false} |
| Element snaps back | Check dragConstraints + dragElastic |
| Momentum feels wrong | dragMomentum={false} for precise UIs |
| One-direction only | dragElastic={{ top: 0, bottom: 0.5 }} |
Swipe dismiss: Check BOTH distance AND velocity—users expect flicks to work.
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.
requestAnimationFrame(() => ref.focus())aria-live regions| Standard | Size | Tailwind | Physical |
|---|---|---|---|
| Material Design | 48Ă—48 dp | min-h-12 min-w-12 |
~9mm (recommended) |
| Apple HIG | 44Ă—44 pt | min-h-11 min-w-11 |
~7mm |
| WCAG 2.2 (AA) | 24Ă—24 px | min-h-6 min-w-6 |
~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.
transform and opacity—GPU-acceleratedwidth, height, top, left, margin, paddingwill-change sparingly—only during animation, remove after20px: Avoid for real-time effects; use pre-blurred images instead
layout prop, not animate={{ height }}opacity: 0 still receives clicks—add pointerEvents: "none"See references/recipes.md for detailed examples.
Copy-paste patterns organized by category in references/recipes.md:
layout prop, layoutId shared elements, collision fixesforceMount pattern, asChild, origin-aware popoverslayout), invisible-but-clickable fix, will-changepopLayout with forwardRef, SSR hydration (initial={false})| Mode | Behavior | Use Case |
|---|---|---|
sync (default) |
Simultaneous enter/exit | Crossfades, overlays |
wait |
Exit completes before enter | Page transitions, tabs |
popLayout |
Exiting elements leave flow | List removals (with layout) |
Exit animations require AnimatePresence—without it, unmount is instant:
// ❌ 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.
| Don't | Do Instead | Why |
|---|---|---|
scale(0) start |
scale(0.9) or higher |
Avoids "popping" effect |
linear for UI |
ease-out 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 motion-safe: |
Accessibility |
| Animate layout props | Use transform: scale() |
Performance |
| Excessive bounce | bounce: 0-0.2 |
Unprofessional |
Tailwind v4: Define keyframes via
@themein 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 | delay-150 duration-500 |
| Fill Mode | fill-mode-forwards fill-mode-backwards |
| When | Skill | Why |
|---|---|---|
| After implementing | code-quality |
Ensure code passes checks |
| Reusable patterns | docs-write |
Document component API |
| Before committing | git-commit |
Use feat(ui): or style: |
| Integration issues | search |
Look up latest patterns |
.ada/ outputs)references/recipes.md - Copy-paste patterns, integration examples, detailed traps