Motion Design Principles
Three-Tier Animation Decision Tree
Choose the SIMPLEST tier that achieves the effect. Never escalate without reason.
Tier 1: CSS Scroll-Driven Animations
Use when: Simple reveals, hover effects, viewport-triggered fades.
Technology: CSS animation-timeline: scroll(), @keyframes, transition.
Advantages: Zero JS, best performance, compositor-only.
/* Tier 1 example: simple fade-up on scroll */
@keyframes fade-up {
from { opacity: 0; transform: translateY(var(--motion-reveal-distance, 40px)); }
to { opacity: 1; transform: translateY(0); }
}
.reveal-on-scroll {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
Decision: If a simple CSS scroll-driven animation achieves the effect, STOP HERE.
Tier 2: GSAP ScrollTrigger
Use when: Choreographed sequences, pinning, horizontal scroll, split text, staggered children, scrub-linked animations. Technology: GSAP 3 + ScrollTrigger plugin + optionally SplitText, Flip. Advantages: Timeline control, pin support, scrub, responsive via matchMedia.
Decision: If the effect needs timeline choreography, scrub, or pinning, use GSAP.
Tier 3: Three.js / WebGL
Use when: 3D scenes, particle systems, model rendering, camera scroll animation. Technology: three@0.169.0 (vanilla, NOT React Three Fiber). Advantages: Full 3D, GPU-powered, immersive experiences.
Decision: Only when the effect is inherently 3D. Never for 2D scroll effects.
Composition Rules
Stagger Timing
- Base stagger: use design-dna
motion.duration.stagger(default 0.12s) - Max visible staggers: 8-12 elements. Beyond that, use batch reveals.
- Stagger direction: follow reading order (left-to-right, top-to-bottom)
Easing Selection by Context
| Context | Easing | Design-DNA Token |
|---------|--------|-----------------|
| Element entering viewport | Decelerate | motion.easing.entrance |
| Element leaving viewport | Accelerate | motion.easing.exit |
| Emphasis/attention | Elastic/bounce | motion.easing.emphasis |
| Continuous/scrub | Linear or none | motion.easing.smooth |
Duration Relationships
- Fast interactions (hover, click feedback):
motion.duration.fast(0.3s) - Standard reveals:
motion.duration.normal(0.6s) - Dramatic/hero sequences:
motion.duration.slow(1.0s) - Between-element stagger:
motion.duration.stagger(0.12s) - Rule: Longer distances = longer durations. Scale proportionally.
Do / Don't
DO
- Consume ALL timing/easing values from design-dna motion tokens
- Clean up ScrollTrigger instances on component unmount
- Use
gsap.matchMedia()for responsive animation differences - Use
will-changesparingly and remove after animation completes - Animate only
transformandopacitywhen possible (compositor-only) - Respect
prefers-reduced-motion: disable or simplify animations - Use
ScrollTrigger.batch()for many identical reveal animations
DON'T
- Hardcode animation values (durations, easings, distances)
- Mix
scrubandtoggleActionson the same ScrollTrigger - Put ScrollTrigger on child tweens inside a timeline (put it on the timeline)
- Animate layout properties (
width,height,top,left) -- use transforms - Use
transition: all-- list specific properties - Forget cleanup: always
tl.revert()orScrollTrigger.kill()on unmount - Create animations without testing on mobile viewports
Anti-Patterns
ScrollTrigger on Child Tweens (CRITICAL)
// WRONG: ScrollTrigger on individual tweens inside timeline
const tl = gsap.timeline();
tl.to('.a', { y: 0, scrollTrigger: { trigger: '.a' } }); // BUG
tl.to('.b', { y: 0, scrollTrigger: { trigger: '.b' } }); // BUG
// RIGHT: ScrollTrigger on the timeline itself
const tl = gsap.timeline({
scrollTrigger: { trigger: '.section', start: 'top center' }
});
tl.to('.a', { y: 0 });
tl.to('.b', { y: 0 });
Missing Cleanup
// WRONG: No cleanup
useEffect(() => {
gsap.to('.element', { scrollTrigger: { ... } });
}, []);
// RIGHT: Context-based cleanup
useEffect(() => {
const ctx = gsap.context(() => {
gsap.to('.element', { scrollTrigger: { ... } });
}, containerRef);
return () => ctx.revert();
}, []);
Missing Responsive Handling
// WRONG: Same animation at all viewport sizes
gsap.to('.hero', { x: 500 });
// RIGHT: Responsive via matchMedia
gsap.matchMedia().add('(min-width: 768px)', () => {
gsap.to('.hero', { x: 500 });
});
gsap.matchMedia().add('(max-width: 767px)', () => {
gsap.to('.hero', { x: 200 });
});
Responsive Animation Principles
- Always use
gsap.matchMedia()for viewport-dependent animations - Simplify on mobile: Reduce parallax intensity, disable horizontal scroll sections
- Touch considerations: No hover-dependent animations on mobile
- Performance budget: Fewer simultaneous animations on mobile
- Reduced motion: Check
prefers-reduced-motionand provide fallback
Performance Guidelines
- Batch similar animations:
ScrollTrigger.batch()for repeated reveals - Transforms only: Animate
transformandopacityfor 60fps - Will-change budget: Apply to max 3-5 elements simultaneously
- Lazy ScrollTrigger: Use
ScrollTrigger.create()with lazy refresh for off-screen content - Pin spacing: Always set
pinSpacing: true(default) unless layout requires otherwise
Aesthetic capture is owner-gated — see ~/.claude/docs/reference/design-lane.md (Aesthetic capture). No closing capture question.