Animation & Motion
Build UI animations with correct easing, duration, and choreography. Spring physics, scroll-triggered reveals, and reduced-motion safety.
Build purposeful UI animation — motion that communicates state, guides attention, and makes interfaces feel alive without feeling slow.
The Decision Framework
Before writing any animation code, answer these in order:
- Should this animate? Not everything should. If the animation doesn't serve feedback, orientation, focus, or continuity — skip it.
- What is the purpose? Feedback (button press), orientation (page transition), focus (draw eye to change), continuity (element morphs between states).
- What easing? Custom curves only. CSS
easeis too weak. Usecubic-bezier(0.4, 0, 0.2, 1)for exits,cubic-bezier(0, 0, 0.2, 1)for entrances. Neverease-infor UI — it starts slow, feels sluggish. - How fast? Under 300ms for micro-interactions. 150ms for hovers. 200–250ms for dropdowns and modals. 300–500ms for page transitions. Faster feels more responsive even when load time is identical.
Spring Physics
Springs feel more natural than duration-based animations because they simulate real physics. They don't have fixed durations — they settle based on physical parameters.
- Stiffness: higher = snappier (100–300 for UI).
- Damping: higher = less bounce (15–25 for UI).
- Bounce: keep subtle (0.1–0.3). Avoid in most UI contexts. Reserve for drag-to-dismiss and playful interactions.
- Interruptibility advantage: springs maintain velocity when interrupted — CSS animations restart from zero. Ideal for gestures users might change mid-motion.
Use Motion (Framer Motion) for React: useSpring interpolates value changes with spring-like behavior instead of updating immediately.
What to Animate
Safe properties (GPU-composited, no layout thrash):
transform(translate, scale, rotate)opacityfilter(blur, brightness — use sparingly)clip-path(one of the most underrated animation properties)box-shadow(for elevation changes)
Never animate unless truly necessary:
width,height,top,left,right,bottommargin,paddingborder-width- Any property that triggers layout recalculation
Enter Animations
Use @starting-style for enter states:
.dialog {
opacity: 0;
transform: scale(0.95) translateY(8px);
transition: opacity 200ms, transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
@starting-style {
.dialog { opacity: 0; transform: scale(0.95) translateY(8px); }
}
.dialog[open] {
opacity: 1;
transform: scale(1) translateY(0);
}
Never animate from scale(0) — it creates a flash. Start from scale(0.95) or scale(0.98).
Make popovers origin-aware: a popover opening from a button should transform from that button's position, not from center screen.
Stagger Animations
Staggering items within one list is legitimate. The tell is the uniform reflex — one identical entrance applied to every section. Each reveal should fit what it reveals. Vary timing, distance, and direction based on content role.
Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition — transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank.
Scroll-Triggered Motion
Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth.
For scroll-linked effects:
- Use
IntersectionObserverfor triggering, not scroll position math. animation-timeline: scroll()for CSS-only scroll-driven animations (modern browsers).- Keep scroll animations subtle — parallax of 20–40px, not 200px.
Performance Rules
- CSS animations beat JS under load. Prefer CSS when the animation is declarative.
- Use WAAPI (Web Animations API) for programmatic CSS animations — same performance as CSS keyframes, JS control.
- Hardware acceleration caveat:
will-changepromotes to compositor layer but costs memory. Use for active animations, remove after. prefers-reduced-motionis not optional. Every animation needs an alternative: typically a crossfade or instant transition.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Component-Specific Patterns
- Dropdowns: 150ms, ease-out-quart. Origin from trigger button.
- Modals: 200ms, scale 0.95→1 + opacity. Backdrop fades separately at 150ms.
- Toasts: slide in from edge (translateX), 250ms spring. Exit same direction, 200ms.
- Page transitions: fade + subtle vertical shift (8px). 300ms max.
- Hover states: 100–150ms. Fast enough to feel instant, slow enough to not flash.
- Loading spinners: faster spin = feels faster load. 800ms rotation feels quicker than 1200ms.
Anti-Patterns
ease-infor UI animations (feels sluggish).- Animating from
scale(0)(visual flash). - Bounce/elastic easing in production UI (save for playful contexts).
- Animating layout properties without
will-changeor containment. - Uniform stagger intervals for heterogeneous content.
- Gating content visibility on reveal classes (blank page on headless render).
- Arbitrary values like
transition: all 0.3s— specify properties explicitly. - Ignoring
prefers-reduced-motion.
Verification
- Toggle
prefers-reduced-motionin OS settings — all animations degrade gracefully. - Tab through the interface — focus transitions should be visible but not distracting.
- Rapidly click/trigger the animation multiple times — it should interrupt cleanly, not queue.
- Profile in DevTools: no layout thrashing (purple bars in Performance tab).
- Test on a slow device: animations should still hit 60fps or degrade to simpler alternatives.