UI Animation
Tasteful UI animation with proper timing, accessibility, and performance.
Quick Start
Technique Decision:
- Simple transition? → CSS/Tailwind (
transition-*, animate-*)
- Enter/exit with unmount? → Motion +
AnimatePresence
- Gesture-driven? → Motion springs
- Layout changes? → Motion
layout 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
prefers-reduced-motion. 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 layout prop |
| Shared element |
Tab indicator, card expand |
Motion layoutId |
| 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
references/recipes.md for copy-paste patterns
- Apply timing from Step 2
- Wrap unmounting elements in
AnimatePresence
Use ui_to_artifact when starting from a design screenshot or mockup. Use ui_diff_check to compare expected vs implemented UI.
Step 4: Accessibility (Required)
- Check
prefers-reduced-motion with useReducedMotion() or motion-safe:
- Simplify to opacity-only for reduced motion users
- Verify focus timing (move focus AFTER animation starts)
Step 5: Verify
Easing
Decision Tree
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) |
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 |
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 ease-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 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 |
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: stiffness/damping/mass overrides duration/bounce. Pick one approach—don't mix.
Layout Animations
The layout Prop
Add 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 |
Shared Element Transitions (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 Gotchas
- Text distortion: Apply
layout="position" to text elements
- Border radius: Can warp during scale—Motion auto-corrects, but test it
- SVG elements:
layout doesn't work on <path>—use manual morphing
Gesture Gotchas
| 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.
Accessibility
prefers-reduced-motion (REQUIRED)
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
aria-live regions
Touch Targets
| 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.
Performance
Golden Rules
- Only animate
transform and opacity—GPU-accelerated
- Never animate:
width, height, top, left, margin, padding
will-change 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
layout prop, not animate={{ height }}
- Invisible but clickable:
opacity: 0 still receives clicks—add pointerEvents: "none"
- will-change everywhere: Causes layer explosion, mobile crashes
See references/recipes.md for detailed examples.
Examples
Copy-paste patterns organized by category in references/recipes.md:
- 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:
layout prop, layoutId shared elements, collision fixes
- Radix UI Integration:
forceMount pattern, asChild, origin-aware popovers
- Accessibility: Focus timing, focus restoration, reduced motion variants
- Performance: Height animation (use
layout), invisible-but-clickable fix, will-change
- Exit Patterns:
popLayout with forwardRef, SSR hydration (initial={false})
- Gestures: Swipe dismiss with velocity check, elastic drag boundaries
AnimatePresence
| 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 Animation Trap
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.
Anti-patterns
| 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 |
tailwindcss-animate
Tailwind v4: Define keyframes via @theme 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 |
delay-150 duration-500 |
| Fill Mode |
fill-mode-forwards fill-mode-backwards |
Integration with Other Skills
| 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 |
Output
- Artifacts: Code changes only (no
.ada/ outputs)
- Modifications: Component animations, CSS/Tailwind styles, Motion configs
- Type: Workflow skill (guidance only, no scripts)
References
Internal
references/recipes.md - Copy-paste patterns, integration examples, detailed traps
External
Converted and distributed by TomeVault — claim your Tome and manage your conversions.
1---2name: lukasstrickler-ai-dev-atelier-ui-animation3description: UI Animation4---56# UI Animation78Tasteful UI animation with proper timing, accessibility, and performance.910## Quick Start1112**Technique Decision:**13- **Simple transition?** → CSS/Tailwind (`transition-*`, `animate-*`)14- **Enter/exit with unmount?** → Motion + `AnimatePresence`15- **Gesture-driven?** → Motion springs16- **Layout changes?** → Motion `layout` prop1718**Default:** Start with Easing Decision Tree → Duration Guidelines → Implement → Add a11y → Verify1920## Core Principles21221. **Natural Motion**: Mimic physics. Avoid linear easing - nothing moves at constant speed.232. **Purposeful**: Every animation must add meaning. If you can't explain its benefit, remove it.243. **Fast**: UI animations under 300ms. Hover effects under 150ms. Over 500ms feels sluggish.254. **Interruptible**: Use springs for gesture-driven animations - they handle interruption gracefully.265. **Accessible**: Always respect `prefers-reduced-motion`. Non-negotiable.2728## Workflow2930### Step 1: Classify Animation Type3132| Type | Examples | Technique |33|------|----------|-----------|34| Micro-interaction | Button press, toggle, checkbox | CSS/Tailwind |35| Enter/Exit | Modal, toast, dropdown | Motion + AnimatePresence |36| Layout change | Accordion, reorder, expand | Motion `layout` prop |37| Shared element | Tab indicator, card expand | Motion `layoutId` |38| Gesture | Drag, swipe, pull-to-refresh | Motion springs |3940### Step 2: Choose Timing41421. Use **Easing Decision Tree** below to select curve432. Use **Duration Guidelines** to select timing443. For gestures, use **Spring Animations** config4546### Step 3: Implement47481. Check `references/recipes.md` for copy-paste patterns492. Apply timing from Step 2503. Wrap unmounting elements in `AnimatePresence`5152Use `ui_to_artifact` when starting from a design screenshot or mockup. Use `ui_diff_check` to compare expected vs implemented UI.5354### Step 4: Accessibility (Required)55561. Check `prefers-reduced-motion` with `useReducedMotion()` or `motion-safe:`572. Simplify to opacity-only for reduced motion users583. Verify focus timing (move focus AFTER animation starts)5960### Step 5: Verify6162- [ ] Exit animations run (not instant unmount)63- [ ] `opacity: 0` elements have `pointerEvents: none`64- [ ] Focus moves after animation starts, not before65- [ ] Only animating `transform` and `opacity`6667## Easing6869### Decision Tree7071```text72What triggers the animation?73│74├─ User action (click, tap, open)?75│ └─ Use: ease-out (fast start, slow end = responsive)76│77├─ Element moving on-screen (tab switch, reorder)?78│ └─ Use: ease-in-out (accelerate then decelerate)79│80├─ Continuous/looping (spinner, marquee)?81│ └─ Use: linear (constant speed appropriate here)82│83├─ Gesture-based (drag, swipe, pull)?84│ └─ Use: Spring animation (physics-based, interruptible)85│86└─ Hover/focus effect?87 └─ Use: CSS ease, 150ms (subtle, immediate)88```8990### Quick Reference9192| Purpose | CSS | Tailwind | Duration |93|---------|-----|----------|----------|94| Modal/drawer enter | `cubic-bezier(0.32, 0.72, 0, 1)` | `ease-out duration-200` | 200ms |95| Modal/drawer exit | `cubic-bezier(0.32, 0.72, 0, 1)` | `ease-out duration-150` | 150ms |96| On-screen movement | `cubic-bezier(0.4, 0, 0.2, 1)` | `ease-in-out duration-200` | 200-300ms |97| Hover effect | `ease` | `ease duration-150` | 150ms |98| Button press | — | `active:scale-[0.97]` | instant |99100### Pro Curves101102| Name | Value | Use Case |103|------|-------|----------|104| **Vaul (buttery)** | `cubic-bezier(0.32, 0.72, 0, 1)` | Sheets, drawers, modals |105| **Emphasized** | `cubic-bezier(0.2, 0, 0, 1)` | Material Design 3 |106| **Snappy** | `cubic-bezier(0.25, 1, 0.5, 1)` | Fast UI transitions |107108**Avoid**: Built-in `ease-in`—starts slow, feels sluggish.109110## Duration Guidelines111112| Type | Duration | Notes |113|------|----------|-------|114| Micro-feedback | 100-150ms | Button press, toggle, checkbox |115| Small transition | 150-250ms | Tooltip, icon morph |116| Medium transition | 200-300ms | Modal, popover, dropdown |117| Large transition | 300-400ms | Page transition, complex layout |118| **Maximum** | <500ms | Exceptions: onboarding, data viz |119120**Key Rules**:121- **Exit faster than enter**: 200ms enter → 150ms exit122- **Hover = fast**: Under 150ms123- **High-frequency = instant**: Keyboard nav, scrolling—<100ms or none124125## Spring Animations126127### Duration-based (Recommended)128129Easier to compose with other timed animations. Use `visualDuration` (time to visually reach target) and `bounce` (0 = no bounce, 1 = very bouncy).130131| Feel | Config | Use Case |132|------|--------|----------|133| **Snappy** | `{ duration: 0.3, bounce: 0.15 }` | Tabs, buttons, quick feedback |134| **Standard** | `{ duration: 0.4, bounce: 0.2 }` | Modals, menus, general UI |135| **Gentle** | `{ duration: 0.5, bounce: 0.25 }` | Smooth, human-like flow |136137### Physics-based (Legacy/Advanced)138139Use when integrating with physics libraries or when precise control over spring dynamics is needed.140141| Feel | Config | Use Case |142|------|--------|----------|143| **Snappy** | `{ stiffness: 400, damping: 30 }` | High-frequency interactions |144| **Standard** | `{ stiffness: 300, damping: 20 }` | Framer Handshake convention |145| **Gentle** | `{ stiffness: 120, damping: 14 }` | react-motion preset |146147> **Gotcha**: `stiffness`/`damping`/`mass` overrides `duration`/`bounce`. Pick one approach—don't mix.148149## Layout Animations150151### The `layout` Prop152153Add `layout` to animate position/size changes automatically. Use `layout="position"` for text (prevents distortion).154155| Prop Value | Effect | Use Case |156|------------|--------|----------|157| `layout={true}` | Animates position AND size | Default for flexible elements |158| `layout="position"` | Animates only translation | Text/icons that shouldn't stretch |159| `layout="size"` | Animates only dimensions | Fixed-position expanding panels |160161### Shared Element Transitions (`layoutId`)162163Elements with matching `layoutId` animate between each other when entering/exiting.164165**Critical Trap**: Duplicate `layoutId` values cause elements to **teleport across the page**. Use unique IDs per context or wrap in `<LayoutGroup id="...">`.166167### Layout Gotchas168169- **Text distortion**: Apply `layout="position"` to text elements170- **Border radius**: Can warp during scale—Motion auto-corrects, but test it171- **SVG elements**: `layout` doesn't work on `<path>`—use manual morphing172173## Gesture Gotchas174175| Problem | Solution |176|---------|----------|177| Touch scroll conflicts | `dragPropagation={false}` |178| Element snaps back | Check `dragConstraints` + `dragElastic` |179| Momentum feels wrong | `dragMomentum={false}` for precise UIs |180| One-direction only | `dragElastic={{ top: 0, bottom: 0.5 }}` |181182**Swipe dismiss**: Check BOTH distance AND velocity—users expect flicks to work.183184## Accessibility185186### prefers-reduced-motion (REQUIRED)187188```tsx189import { useReducedMotion } from "motion/react"190191const shouldReduce = useReducedMotion()192const variants = shouldReduce 193 ? { opacity: 1 } // Fade only194 : { opacity: 1, scale: 1, y: 0 } // Full animation195```196197Tailwind: `motion-safe:animate-pulse` / `motion-reduce:transition-none`198199**Best practice**: Don't disable—simplify. Remove spatial movement, keep opacity.200201### Focus Management202203- Move focus AFTER animation starts: `requestAnimationFrame(() => ref.focus())`204- Restore focus to trigger on close205- Don't animate inside `aria-live` regions206207### Touch Targets208209| Standard | Size | Tailwind | Physical |210|----------|------|----------|----------|211| **Material Design** | 48×48 dp | `min-h-12 min-w-12` | ~9mm (recommended) |212| **Apple HIG** | 44×44 pt | `min-h-11 min-w-11` | ~7mm |213| **WCAG 2.2 (AA)** | 24×24 px | `min-h-6 min-w-6` | ~5mm (minimum) |214215**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.216217## Performance218219### Golden Rules2202211. **Only animate `transform` and `opacity`**—GPU-accelerated2222. **Never animate**: `width`, `height`, `top`, `left`, `margin`, `padding`2233. **`will-change` sparingly**—only during animation, remove after2244. **Blur thresholds**:225 - ≤10px: Safe for animation226 - 11-20px: May cause jank on mobile/4K—test thoroughly227 - >20px: Avoid for real-time effects; use pre-blurred images instead2285. **Prefer CSS over JS** for simple transitions229230### Key Traps231232- **Height animation**: Use `layout` prop, not `animate={{ height }}`233- **Invisible but clickable**: `opacity: 0` still receives clicks—add `pointerEvents: "none"`234- **will-change everywhere**: Causes layer explosion, mobile crashes235236See `references/recipes.md` for detailed examples.237238## Examples239240Copy-paste patterns organized by category in `references/recipes.md`:241242- **Common UI Patterns**: Button press, modal enter/exit, error shake, staggered lists, accordion243- **Touch & Interaction**: Accessible touch targets, hover on touch devices, instant tooltips244- **Layout Animations**: `layout` prop, `layoutId` shared elements, collision fixes245- **Radix UI Integration**: `forceMount` pattern, `asChild`, origin-aware popovers246- **Accessibility**: Focus timing, focus restoration, reduced motion variants247- **Performance**: Height animation (use `layout`), invisible-but-clickable fix, `will-change`248- **Exit Patterns**: `popLayout` with `forwardRef`, SSR hydration (`initial={false}`)249- **Gestures**: Swipe dismiss with velocity check, elastic drag boundaries250251## AnimatePresence252253| Mode | Behavior | Use Case |254|------|----------|----------|255| `sync` (default) | Simultaneous enter/exit | Crossfades, overlays |256| `wait` | Exit completes before enter | Page transitions, tabs |257| `popLayout` | Exiting elements leave flow | List removals (with `layout`) |258259### Exit Animation Trap260261Exit animations require `AnimatePresence`—without it, unmount is instant:262263```tsx264// ❌ Exit never runs265{isOpen && <motion.div exit={{ opacity: 0 }}>...</motion.div>}266267// ✅ Wrap in AnimatePresence268<AnimatePresence>269 {isOpen && <motion.div exit={{ opacity: 0 }}>...</motion.div>}270</AnimatePresence>271```272273**SSR**: Use `<AnimatePresence initial={false}>` to prevent animation on page load.274275## Anti-patterns276277| Don't | Do Instead | Why |278|-------|------------|-----|279| `scale(0)` start | `scale(0.9)` or higher | Avoids "popping" effect |280| `linear` for UI | `ease-out` or springs | Linear feels robotic |281| Animations >500ms | Keep under 300ms | Feels sluggish |282| Same tooltip delay | First: 400ms, subsequent: 0ms | User mental model |283| Skip reduced-motion | Always `motion-safe:` | Accessibility |284| Animate layout props | Use `transform: scale()` | Performance |285| Excessive bounce | `bounce: 0-0.2` | Unprofessional |286287## tailwindcss-animate288289> **Tailwind v4**: Define keyframes via `@theme` in CSS, not config.290291| Category | Classes |292|----------|---------|293| Enter | `animate-in fade-in zoom-in-95 slide-in-from-top` |294| Exit | `animate-out fade-out zoom-out-95 slide-out-to-top` |295| Timing | `delay-150 duration-500` |296| Fill Mode | `fill-mode-forwards fill-mode-backwards` |297298## Integration with Other Skills299300| When | Skill | Why |301|------|-------|-----|302| After implementing | `code-quality` | Ensure code passes checks |303| Reusable patterns | `docs-write` | Document component API |304| Before committing | `git-commit` | Use `feat(ui):` or `style:` |305| Integration issues | `search` | Look up latest patterns |306307## Output308309- **Artifacts**: Code changes only (no `.ada/` outputs)310- **Modifications**: Component animations, CSS/Tailwind styles, Motion configs311- **Type**: Workflow skill (guidance only, no scripts)312313## References314315### Internal316317- [`references/recipes.md`](references/recipes.md) - Copy-paste patterns, integration examples, detailed traps318319### External320321- [Motion Documentation](https://motion.dev/docs)322- [Material Design 3 Motion](https://m3.material.io/styles/motion)323- [Apple HIG - Motion](https://developer.apple.com/design/human-interface-guidelines/motion)324- [tailwindcss-animate](https://github.com/jamiebuilds/tailwindcss-animate)325- [easings.net](https://easings.net) - Easing function cheat sheet326327---328> Converted and distributed by [TomeVault](https://tomevault.io/claim/lukasstrickler) — claim your Tome and manage your conversions.329<!-- tomevault:4.0:skill_md:2026-04-11 -->