Animation Designer
Core Principle
Every animation must answer: what purpose does this serve? Valid purposes are:
- Guide attention — direct the user's eye to what matters next.
- Provide feedback — confirm an action was received (button press, form submit).
- Establish continuity — connect two states so spatial relationships stay clear.
- Communicate hierarchy — staggered entrances show parent-child relationships.
If an animation serves none of these, remove it. Motion without purpose is noise.
Performance Rules
Follow these rules on every animation. Violations cause layout thrashing, dropped frames, and battery drain.
Only Animate Composite Properties
Animate transform and opacity exclusively. These run on the GPU compositor thread and skip layout/paint. Never animate width, height, top, left, margin, or padding — they trigger full layout recalculation.
/* CORRECT — compositor only */
.card-enter {
transform: translateY(20px) scale(0.98);
opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease-out;
}
/* WRONG — triggers layout on every frame */
.card-enter {
margin-top: 20px;
height: 0;
transition: margin-top 0.4s, height 0.4s;
}
will-change Discipline
Apply will-change only to elements about to animate, and remove it after. Permanent will-change on many elements wastes GPU memory and can degrade performance.
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto';
});
Never set will-change on more than ~10 elements simultaneously. Never put will-change: transform in a global stylesheet applied to hundreds of elements.
Respect prefers-reduced-motion
Wrap all non-essential animations in a reduced-motion check. This is a hard requirement, not optional polish.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// JS runtime check
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Framer Motion
<motion.div
animate={{ opacity: 1, y: prefersReduced ? 0 : 20 }}
transition={prefersReduced ? { duration: 0 } : { duration: 0.4 }}
/>
requestAnimationFrame for Custom Animations
Never use setInterval or setTimeout for frame-by-frame animation. Use requestAnimationFrame to sync with the browser's paint cycle.
function animateValue(start: number, end: number, duration: number, onUpdate: (v: number) => void) {
const startTime = performance.now();
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // cubic ease-out
onUpdate(start + (end - start) * eased);
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
Design Principles
Easing Over Duration
Duration matters less than easing curve. A 300ms animation with the right easing feels faster than a 200ms linear animation. Default curves for common actions:
| Action | Easing | CSS Value | Why |
|---|---|---|---|
| Enter/appear | Decelerate | cubic-bezier(0.0, 0.0, 0.2, 1) |
Elements arrive and settle into place |
| Exit/disappear | Accelerate | cubic-bezier(0.4, 0.0, 1, 1) |
Elements pick up speed as they leave |
| Move/resize | Standard | cubic-bezier(0.4, 0.0, 0.2, 1) |
Balanced for repositioning |
| Bounce/playful | Spring | cubic-bezier(0.34, 1.56, 0.64, 1) |
Overshoots target then settles |
| Micro-interaction | Snap | cubic-bezier(0.16, 1, 0.3, 1) |
Quick start, gentle land |
Duration guidelines: micro-interactions 100-200ms, standard transitions 200-400ms, complex choreography 400-800ms. Never exceed 1s for UI animations.
Choreography
When multiple elements animate, stagger them 50-80ms apart in reading order (top-to-bottom, left-to-right). This creates a cascade that communicates hierarchy. Never animate everything simultaneously — it looks like a glitch.
Spatial Consistency
Elements should enter from where they logically originate. A dropdown enters from the top. A sidebar slides from the side. A modal scales from its trigger button. Breaking spatial logic confuses users about where elements "live" in the interface.
CSS Animations
@keyframes for Repeating or Multi-Step Animations
Use @keyframes when an animation has more than two states or loops. Use transition for simple A-to-B state changes.
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.skeleton {
position: relative;
overflow: hidden;
background: #e2e8f0;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shimmer 1.5s infinite;
}
Scroll-Driven Animations (CSS Native)
Use the CSS animation-timeline property with scroll() or view() for scroll-linked effects without JavaScript. This runs entirely on the compositor.
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll(root);
}
@keyframes parallax {
from { transform: translateY(0); }
to { transform: translateY(-30%); }
}
/* Reveal elements as they enter viewport */
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
View Transitions API
Use for page-level transitions in SPAs and MPAs. Assign view-transition-name to elements that persist across navigations to morph them smoothly.
.hero-image {
view-transition-name: hero;
}
::view-transition-old(hero) {
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) fade-out;
}
::view-transition-new(hero) {
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) fade-in;
}
// Trigger transition in SPA
document.startViewTransition(() => {
updateDOM(); // synchronous DOM update
});
Framer Motion
Reference: references/framer-motion.md
Layout Animations
Use layout prop for automatic FLIP animations when element position or size changes. This avoids manual calculation and always animates transform under the hood.
<motion.div layout layoutId="card-expand" transition={{ type: "spring", stiffness: 300, damping: 30 }}>
{isExpanded ? <ExpandedContent /> : <CollapsedContent />}
</motion.div>
AnimatePresence for Exit Animations
Wrap lists or conditional elements to animate them out before React unmounts them. Without AnimatePresence, exit animations are impossible because the DOM node is removed immediately.
<AnimatePresence mode="popLayout">
{items.map((item) => (
<motion.li
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.3 }}
/>
))}
</AnimatePresence>
Staggered List Entrance
Use staggerChildren in a parent's transition to cascade child animations.
const container = {
hidden: {},
show: { transition: { staggerChildren: 0.06 } },
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] } },
};
<motion.ul variants={container} initial="hidden" animate="show">
{items.map((i) => <motion.li key={i.id} variants={item} />)}
</motion.ul>
Scroll-Triggered with useInView
function RevealSection({ children }: { children: React.ReactNode }) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-100px" });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 40 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>
);
}
Gesture Animations
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
/>
GSAP
Reference: references/gsap.md
Timelines for Coordinated Sequences
Use gsap.timeline() when multiple elements must animate in a precise sequence. Timelines provide position parameters for exact overlap control.
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } });
tl.from(".hero-title", { y: 60, opacity: 0 })
.from(".hero-subtitle", { y: 40, opacity: 0 }, "-=0.3") // starts 0.3s before previous ends
.from(".hero-cta", { scale: 0.9, opacity: 0 }, "-=0.2")
.from(".hero-image", { x: 100, opacity: 0 }, "<"); // starts at same time as previous
ScrollTrigger
gsap.from(".feature-card", {
scrollTrigger: {
trigger: ".features-section",
start: "top 80%",
end: "bottom 20%",
toggleActions: "play none none reverse",
},
y: 60,
opacity: 0,
stagger: 0.1,
duration: 0.8,
ease: "power3.out",
});
Text Split Animation
Split text into spans, then stagger them. GSAP's SplitText plugin handles this, or build manually for fewer dependencies.
// Manual split approach
function splitText(element: HTMLElement) {
const text = element.textContent ?? '';
element.innerHTML = text
.split('')
.map((char) => `<span class="char" style="display:inline-block">${char === ' ' ? ' ' : char}</span>`)
.join('');
return element.querySelectorAll('.char');
}
const chars = splitText(document.querySelector('.headline')!);
gsap.from(chars, { y: 40, opacity: 0, stagger: 0.03, duration: 0.5, ease: "back.out(1.7)" });
SVG Morphing
Use GSAP's MorphSVG plugin or flubber for path interpolation between SVG shapes.
Three.js / React Three Fiber
Reference: references/threejs-r3f.md for full examples (scenes, particles, cameras, shaders).
Use R3F's useFrame for the animation loop. Use InstancedMesh or <points> for particle systems — never individual meshes per particle. Cap dpr at [1, 2] to prevent GPU overload on high-DPI screens. Set frameloop="demand" for scenes that only change on interaction.
Key rules: keep draw calls under 100, total triangles under 500K (desktop) / 100K (mobile), use power-of-two textures, and mount the Canvas lazily with Intersection Observer.
Lottie
Use Lottie for complex, designer-authored animations exported from After Effects. Do not use Lottie for simple transitions — CSS or Framer Motion is lighter.
import Lottie from 'lottie-react';
import animationData from './success-check.json';
<Lottie animationData={animationData} loop={false} style={{ width: 120, height: 120 }} />
Keep Lottie JSON files under 50KB. Audit them for embedded images (bloat). Use lottie-light for smaller bundle when only SVG rendering is needed.
Animation Patterns
Page Transitions (SPA)
Combine AnimatePresence with route changes. Animate the outgoing page out, then the incoming page in. Use mode="wait" to prevent layout overlap.
<AnimatePresence mode="wait">
<motion.main
key={pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
{children}
</motion.main>
</AnimatePresence>
Image Reveal with clip-path
.image-reveal {
clip-path: inset(100% 0 0 0);
transition: clip-path 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.image-reveal.visible {
clip-path: inset(0 0 0 0);
}
Cursor-Following Element
Use requestAnimationFrame with linear interpolation (lerp) to smooth the cursor position. A speed factor of 0.1-0.2 creates a pleasant trailing effect. See references/patterns.md for full implementation.
Smooth Scrolling (Lenis)
Initialize Lenis with a custom easing function and feed it via requestAnimationFrame. When combining with GSAP ScrollTrigger, call ScrollTrigger.update() inside the Lenis scroll callback — without this, they fight over scroll position. See references/patterns.md.
Number Counting Animation
Use requestAnimationFrame with cubic ease-out, triggered by useInView({ once: true }). Apply toLocaleString() for formatted output. See references/patterns.md.
Typewriter Effect
Reveal characters via setInterval at 40-60ms per character. Clean up the interval on unmount. Add a blinking cursor with CSS animation: blink 1s step-end infinite. See references/patterns.md.
Choosing the Right Tool
| Need | Use | Why |
|---|---|---|
| Simple hover/focus states | CSS transitions | Zero JS, zero bundle cost |
| Scroll-linked parallax | CSS scroll-driven animations | Compositor-only, no JS scroll listener |
| Page navigation morphs | View Transitions API | Browser-native, works across documents |
| React component enter/exit | Framer Motion | Handles React lifecycle, unmount animations |
| Complex multi-element sequences | GSAP timeline | Precise timing control, position parameters |
| Scroll-triggered sections | GSAP ScrollTrigger or Framer useInView | Depends on complexity; ScrollTrigger for pinning/scrub |
| 3D scenes, particles | React Three Fiber | Declarative Three.js, React integration |
| Designer-authored complex art | Lottie | Frame-perfect reproduction from After Effects |
| Simple loading indicators | CSS @keyframes | No dependency needed for spinners/shimmers |
Anti-Patterns
- Animating layout properties (width, height, top, left) — causes layout thrashing on every frame.
- will-change on everything — promotes elements to GPU layers permanently, consuming memory.
- No reduced-motion support — violates WCAG 2.1 SC 2.3.3, causes vestibular discomfort.
- Animations longer than 1s for UI elements — users perceive the interface as slow.
- Animating during page load — blocks first meaningful paint; defer non-critical animations.
- Using JS for scroll-linked effects that CSS can handle — JS scroll listeners fire on the main thread.
- Individual mesh per particle in Three.js — use InstancedMesh or Points geometry.
- Lottie for simple transitions — a 200KB JSON for a fade-in is unjustifiable.
