Animations
Interruptible animations, enter/exit transitions, and contextual icon animations.
Interruptible Animations
Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.
CSS Transitions vs. Keyframes
| CSS Transitions | CSS Keyframe Animations | |
|---|---|---|
| Behavior | Interpolate toward latest state | Run on a fixed timeline |
| Interruptible | Yes — retargets mid-animation | No — restarts from beginning |
| Use for | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |
| Duration | Adapts to remaining distance | Fixed regardless of state |
/* Good — interruptible transition for a toggle */
.drawer {
transform: translateX(-100%);
transition: transform 200ms ease-out;
}
.drawer.open {
transform: translateX(0);
}
/* Clicking again mid-animation smoothly reverses — no jank *//* Bad — keyframe animation for interactive element */
.drawer.open {
animation: slideIn 200ms ease-out forwards;
}
/* Closing mid-animation snaps or restarts — feels broken */Rule: Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.
Enter Animations: Split and Stagger
Don't animate a single large container. Break content into semantic chunks and animate each individually.
Step by Step
- Split into logical groups (title, description, buttons)
- Stagger with ~100ms delay between groups
- For titles, consider splitting into individual words with ~80ms stagger
- Combine
opacity,blur, andtranslateYfor the enter effect
Code Example
// Motion (Framer Motion) — staggered enter
function PageHeader() {
return (
<motion.div
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.1 } },
}}
>
<motion.h1
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
Welcome
</motion.h1>
<motion.p
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
A description of the page.
</motion.p>
<motion.div
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
<Button>Get started</Button>
</motion.div>
</motion.div>
);
}CSS-Only Stagger
.stagger-item {
opacity: 0;
transform: translateY(12px);
filter: blur(4px);
animation: fadeInUp 400ms ease-out forwards;
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 100ms; }
.stagger-item:nth-child(3) { animation-delay: 200ms; }
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}Exit Animations
Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.
Subtle Exit (Recommended)
// Small fixed translateY — indicates direction without drama
<motion.div
exit={{
opacity: 0,
y: -12,
filter: "blur(4px)",
transition: { duration: 0.15, ease: "easeIn" },
}}
>
{content}
</motion.div>Full Exit (When Context Matters)
// Slide fully out — use when spatial context is important
// (e.g., a card returning to a list, a drawer closing)
<motion.div
exit={{
opacity: 0,
x: "-100%",
transition: { duration: 0.2, ease: "easeIn" },
}}
>
{content}
</motion.div>Good vs. Bad
/* Good — subtle exit */
.item-exit {
opacity: 0;
transform: translateY(-12px);
transition: opacity 150ms ease-in, transform 150ms ease-in;
}
/* Bad — dramatic exit that steals focus */
.item-exit {
opacity: 0;
transform: translateY(-100%) scale(0.5);
transition: all 400ms ease-in;
}
/* Bad — no exit animation at all (element just vanishes) */
.item-exit {
display: none;
}Key points:
- Use a small fixed
translateY(e.g.,-12px) instead of the full container height - Keep some directional movement to indicate where the element went
- Exit duration should be shorter than enter duration (150ms vs 300ms)
- Don't remove exit animations entirely — subtle motion preserves context
Contextual Icon Animations
When icons appear or disappear contextually (on hover, on state change), animate them with opacity, scale, and blur rather than just toggling visibility.
Motion Example
import { AnimatePresence, motion } from "motion/react";
function IconButton({ isActive, icon: Icon }) {
return (
<button>
<AnimatePresence mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<Icon />
</motion.span>
</AnimatePresence>
</button>
);
}CSS Transition Approach (No Motion)
If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.
The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from 0.25 while the exiting icon scales down to 0.25, both with opacity and blur.
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
return (
<button>
<div className="relative">
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
"transition-[opacity,filter,scale] duration-300",
"cubic-bezier(0.2, 0, 0, 1)",
isActive
? "scale-100 opacity-100 blur-0"
: "scale-[0.25] opacity-0 blur-[4px]"
)}
>
<ActiveIcon />
</div>
<div
className={cn(
"transition-[opacity,filter,scale] duration-300",
"cubic-bezier(0.2, 0, 0, 1)",
isActive
? "scale-[0.25] opacity-0 blur-[4px]"
: "scale-100 opacity-100 blur-0"
)}
>
<InactiveIcon />
</div>
</div>
</button>
);
}The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.
Choosing Between Motion and CSS
| Motion (Framer Motion) | CSS transitions (both icons in DOM) | |
|---|---|---|
| Enter animation | Yes | Yes |
| Exit animation | Yes (via AnimatePresence) | Yes (cross-fade — icon never unmounts) |
| Spring physics | Yes | No — use cubic-bezier(0.2, 0, 0, 1) as approximation |
| When to use | Project already uses motion/react | No motion dependency, or keeping bundle small |
Rule: Check the project's package.json for motion or framer-motion. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.
When to Animate Icons
| Animate | Don't animate |
|---|---|
| Icons that appear on hover (action buttons) | Static navigation icons |
| State change icons (play → pause, like → liked) | Decorative icons |
| Icons in contextual toolbars | Icons that are always visible |
| Loading/success state indicators | Icon labels (text next to icon) |
Important: Always use exactly these values for contextual icon animations — do not deviate:
scale:0.25→1(never use0.5or0.6)opacity:0→1filter:"blur(4px)"→"blur(0px)"transition:{ type: "spring", duration: 0.3, bounce: 0 }— bounce must always be0, never0.1or any other value
Scale on Press
A subtle scale-down on click gives buttons tactile feedback. Always use scale(0.96). Never use a value smaller than 0.95 — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.
Not every button needs this. Add a static prop to your button component that disables the scale effect when the motion would be distracting.
CSS Example
.button {
transition-property: scale;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.button:active {
scale: 0.96;
}Tailwind Example
<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
Click me
</button>Motion Example
<motion.button whileTap={{ scale: 0.96 }}>
Click me
</motion.button>Static Prop Pattern
Extract the scale class into a variable and conditionally apply it based on a static prop:
const tapScale = "active:not-disabled:scale-[0.96]";
function Button({ static: isStatic, className, children, ...props }) {
return (
<button
className={cn(
"transition-transform duration-150 ease-out",
!isStatic && tapScale,
className,
)}
{...props}
>
{children}
</button>
);
}
// Usage
<Button>Click me</Button> {/* scales on press */}
<Button static>Submit</Button> {/* no scale */}Skip Animation on Page Load
Use initial={false} on AnimatePresence to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.
When It Works
// Good — icon doesn't animate in on mount, only on state change
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
>
<Icon />
</motion.span>
</AnimatePresence>Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.
When It Breaks
Don't use initial={false} when the component relies on its initial prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.
// Bad — initial={false} would skip the staggered page enter entirely
<AnimatePresence initial={false}>
<motion.div initial="hidden" animate="visible" variants={...}>
...
</motion.div>
</AnimatePresence>Verify the component still looks right on a full page refresh before applying this.