| name | animating-motion |
| description | Motion design and animation patterns for UI based on Emil Kowalski's principles |
| user-invocable | true |
| allowed-tools | Read, Write, Glob, Grep, Edit |
Animating Motion
Expert motion design and animation patterns for UI, based on Emil Kowalski's "Animations on the Web" principles.
Trigger
/animate
Overview
This skill provides guidance on implementing performant, accessible, and delightful animations in web interfaces. Use it when:
- Adding enter/exit transitions to UI elements
- Implementing drag gestures with momentum
- Optimizing animation performance
- Ensuring accessibility for motion-sensitive users
Quick Decision Tree
Is this element entering or exiting?
└── YES → Use ease-out
Is an on-screen element moving?
└── YES → Use ease-in-out
Is this a hover/color transition?
└── YES → Use ease
Will users see this 100+ times daily?
└── YES → Don't animate it (or drastically reduce)
Workflow
Phase 1: Identify Animation Type
Determine what kind of animation you need:
| Scenario | Easing | Why |
|---|
| Dropdowns, modals, tooltips | ease-out | User-initiated, needs instant response |
| Element moving on screen | ease-in-out | Mimics natural acceleration/deceleration |
| Hover states, color changes | ease | Gentle, elegant transitions |
| Drag with momentum | Spring | Physics-based, interruptible |
Phase 2: Apply Easing Curves
ease-out (Most Common)
Use for user-initiated interactions. Creates instant, responsive feeling.
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
ease-in-out (For Movement)
Use when elements already on screen need to move or morph.
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
ease (For Hover Effects)
Use for hover states and color transitions.
transition: background-color 150ms ease;
linear (Avoid in UI)
Only use for constant-speed animations (marquees, progress indicators).
ease-in (Almost Never)
Avoid - makes interfaces feel sluggish due to slow start.
Phase 3: Set Duration
| Element Type | Duration |
|---|
| Micro-interactions | 100-150ms |
| Standard UI (tooltips, dropdowns) | 150-250ms |
| Modals, drawers | 200-300ms |
| Page transitions | 300-400ms |
Duration Rules:
- UI animations should stay under 300ms
- Larger elements animate slower than smaller ones
- Exit animations can be faster than entrances
- Longer travel distance = longer duration
Phase 4: Ensure Performance
The Golden Rule: Only animate transform and opacity.
These skip layout and paint stages, running entirely on the GPU.
Avoid animating:
padding, margin, height, width (trigger layout)
blur filters above 20px (expensive, especially Safari)
- CSS variables in deep component trees
Optimization:
.animated-element {
will-change: transform;
}
React-specific:
- Animate outside React's render cycle when possible
- Use refs to update styles directly instead of state
- Re-renders on every frame = dropped frames
Framer Motion hardware acceleration:
<motion.div animate={{ transform: "translateX(100px)" }} />
<motion.div animate={{ x: 100 }} />
Phase 5: Add Accessibility
Every animated element needs a prefers-reduced-motion media query:
.modal {
animation: fadeIn 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.modal {
animation: none;
}
}
Reduced Motion Guidelines:
- Set
animation: none or transition: none (no !important)
- No exceptions for opacity or color—disable all animations
- Show play buttons instead of autoplay videos
Framer Motion Implementation:
import { useReducedMotion } from "framer-motion";
function Component() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
/>
);
}
Spring Animations
Springs feel more natural because they simulate real physics with no fixed duration.
When to Use Springs
- Drag interactions with momentum
- Elements that should feel "alive" (Dynamic Island)
- Gestures that can be interrupted mid-animation
- Organic, playful interfaces
Configuration
Apple's approach (recommended):
{ type: "spring", duration: 0.5, bounce: 0.2 }
Traditional physics:
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
Bounce Guidelines
- Avoid bounce in most UI contexts
- Use bounce for drag-to-dismiss, playful interactions
- Keep bounce subtle (0.1-0.3) when used
Interruptibility
Springs maintain velocity when interrupted—CSS animations restart from zero. This makes springs ideal for gestures users might change mid-motion.
Paired Elements Rule
Elements that animate together must use the same easing and duration.
.modal { transition: transform 200ms ease-out; }
.overlay { transition: opacity 200ms ease-out; }
The Frequency Principle
| Usage Frequency | Animation Approach |
|---|
| 100+ times/day | No animation (or drastically reduced) |
| Occasional use | Standard animation |
| Rare/first-time | Can be special |
Example: Raycast never animates its menu toggle because users open it hundreds of times daily.
Practical Solutions
| Scenario | Solution |
|---|
| Make buttons feel responsive | Add transform: scale(0.97) on :active |
| Element appears from nowhere | Start from scale(0.95), not scale(0) |
| Shaky/jittery animations | Add will-change: transform |
| Hover causes flicker | Animate child element, not parent |
| Popover scales from wrong point | Set transform-origin to trigger location |
| Sequential tooltips feel slow | Skip delay/animation after first tooltip |
| Small buttons hard to tap | Use 44px minimum hit area (pseudo-element) |
| Something still feels off | Add subtle blur (under 20px) to mask it |
| Hover triggers on mobile | Use @media (hover: hover) and (pointer: fine) |
Theme Transitions
Important: Switching themes should not trigger transitions. Disable transitions during theme changes to prevent flash of animated content.
AnimatePresence
Use popLayout mode on AnimatePresence when an element has an exit animation and is in a group of elements.
<AnimatePresence mode="popLayout">
{items.map(item => (
<motion.div
key={item.id}
exit={{ opacity: 0, scale: 0.9 }}
/>
))}
</AnimatePresence>
Drag Gestures
When implementing drag-to-dismiss:
- Ensure velocity-based swiping works
- Velocity (
swipeAmount / timeTaken) > 0.10 should trigger action
- Use springs for natural momentum feel
Looping Animations
Pause looping animations when off-screen to save resources:
const ref = useRef(null);
const isInView = useInView(ref);
<motion.div
ref={ref}
animate={isInView ? { rotate: 360 } : {}}
transition={{ repeat: Infinity, duration: 2 }}
/>
CSS vs JavaScript Animations
| Aspect | CSS | JavaScript (Framer Motion) |
|---|
| Performance | Off main thread | Uses requestAnimationFrame |
| Best for | Simple, predetermined | Dynamic, interruptible |
| Interruptibility | Restarts from zero | Maintains velocity |
Counterfactuals — Motion Design
The Target (What We Do)
Apply motion that serves the interaction model: entrances use ease-out (fast start, gentle land), exits use ease-in (gentle start, fast departure), continuous movements use spring physics. Duration scales with distance traveled. Every animation has a clear purpose — guiding attention, confirming action, or showing spatial relationships.
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, ease: [0, 0, 0.2, 1] }}
/>
The Near Miss — Decorative Motion (Seductively Close, But Wrong)
What it looks like: Technically correct animations that don't serve an interaction purpose — elements that bounce, pulse, or slide without communicating state change.
<motion.div
animate={{ scale: [1, 1.02, 1] }}
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
/>
Why it's tempting: Motion makes interfaces feel "alive" and "polished." Subtle animations are praised in design showcases. The animation runs smoothly and doesn't break anything.
Physics of Error: Semantic Drift — Motion without purpose trains users to ignore animation. When every element moves, movement stops signaling state change. The critical animations (loading → loaded, error → recovery, hidden → visible) lose their communicative power because they compete with decorative noise. Worse, infinite animations consume GPU compositing budget on mobile, causing the purposeful animations to stutter.
Detection signal: Any repeat: Infinity animation not attached to a loading/progress state; animations with no corresponding state transition; motion that fires on mount without user trigger.
The Category Error — Wrong Easing Direction (Fundamentally Wrong)
What it looks like: Using ease-in for entrances and ease-out for exits — the physical opposite of real-world motion.
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ease: [0.4, 0, 1, 1] }}
/>
Why someone might try it: "Ease-in" sounds like "easing into view." The naming is misleading. The animation still runs and the element appears.
Physics of Error: Layer Violation — Easing curves encode physical laws. Ease-in means acceleration (slow start, fast end) — objects entering view should decelerate (fast start, slow end = ease-out). Inverting this violates the spatial metaphor that makes animation feel natural. Users perceive the interface as "off" without being able to articulate why, because the motion contradicts the physics their visual system expects. This CANNOT feel natural because it violates the perceptual model that makes animation meaningful rather than decorative.
Bridgebuilder action: Immediate rejection. Regenerate from Target with correct easing direction.
Quick Reference Card
EASING SELECTION:
├── Entering/Exiting → ease-out
├── Moving on screen → ease-in-out
├── Hover/color → ease
├── Drag/gesture → spring
└── Constant speed → linear
DURATION:
├── Micro (100-150ms)
├── Standard (150-250ms)
├── Modal/Drawer (200-300ms)
└── Page (300-400ms)
PERFORMANCE:
├── Only animate: transform, opacity
├── Use: will-change: transform
└── Avoid: layout properties, heavy blur
ACCESSIBILITY:
└── Always add: @media (prefers-reduced-motion: reduce)