Motion & Interaction Engineering
Build motion that communicates, and know when to leave it out.
Phase 2 · Design engineer core · 13 sections · about 12 minutes
Before you start
Motion is how an interface tells you a story. A button that springs into place when pressed feels alive. A page that fades in feels intentional. A transition that stutters feels broken. The hardest skill in motion design is knowing when not to animate, when silence is louder than movement.
This module teaches you motion as communication: from CSS basics through Framer Motion's power tools, and finally to the discipline of restraint.
1. CSS Transitions & Animations: The Foundation
Before reaching for Framer Motion, master CSS. Transitions are the everyday tool. Animations are for orchestrated sequences.
Transitions: State changes, smooth
button { background-color: #3b82f6; transition: background-color 0.3s ease-out;} button:hover { background-color: #2563eb;}The user clicks the button. The background shifts smoothly over 300ms using ease-out (fast start, slow end, natural deceleration). No JavaScript. No framework. This is the baseline.
Key properties:
transition-duration, how long (150ms for micro, 300–500ms for navigations)transition-timing-function, the curve (linear, ease-in, ease-out, ease-in-out, cubic-bezier)transition-delay, stagger for cascadestransition-property, what changes (use specific properties, notall)
Avoid transition: all
/* Bad */button { transition: all 0.3s ease-out;} /* Good */button { transition: background-color 0.3s ease-out, transform 0.2s ease-out;}all couples animations you don't intend to couple. A border-width change shouldn't animate alongside opacity. Specificity is control.
Animations: Orchestrated keyframes
@keyframes slideIn { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); }} .card { animation: slideIn 0.4s ease-out; animation-fill-mode: forwards;}Animations run once (or repeat). They're for loading states, entrance effects, and choreographed sequences. The animation-fill-mode: forwards holds the final state, without it, the element snaps back.
Timing matters:
- Micro-interactions (hover, focus): 100–200ms
- State changes (modal open): 200–400ms
- Page transitions (nav, screen changes): 400–600ms
Anything over 800ms feels stalled. Anything under 100ms feels skipped.
2. Framer Motion: Power & Precision
CSS handles 80% of motion. Framer Motion handles the other 20% that needs coordination: multi-step sequences, gesture responses, shared-layout animations, and spring physics that feel alive.
Installation & setup
npm install framer-motionWrap your component tree in AnimatePresence to handle enter/exit animations when components mount and unmount.
Basic motion component
import { motion } from "framer-motion"; export function FadeInCard() { return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} > <p>This fades in on mount.</p> </motion.div> );}initial, the starting state (before animation)animate, the target state (animate to this)transition, the timing curve and duration
The component mounts with opacity: 0, then Framer Motion animates to opacity: 1 over 500ms.
Springs: Physics feels better
<motion.button initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ type: "spring", stiffness: 200, damping: 15 }}> Click me</motion.button>Springs don't respect duration. Instead, they oscillate around the target, then settle. Adjust:
stiffness, how quickly it accelerates (100–300 for natural, 300+ for snappy)damping, how much resistance (10–30 typical; higher = faster settle)mass, how heavy the object feels (1 default, 0.5 = floaty, 2 = weighty)
Springs feel responsive. They don't feel scripted. Use them for button clicks, entrance animations, and states the user triggers directly.
Orchestration: Stagger & sequence
const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1, delayChildren: 0.2, }, },}; const itemVariants = { hidden: { opacity: 0, y: 10 }, visible: { opacity: 1, y: 0 },}; export function CardList() { return ( <motion.div variants={containerVariants} initial="hidden" animate="visible"> {cards.map((card) => ( <motion.div key={card.id} variants={itemVariants}> {card.title} </motion.div> ))} </motion.div> );}variants are named animation states. staggerChildren: 0.1 delays each child's animation by 100ms, so five cards animate in sequence over 500ms instead of all at once. This creates rhythm without hardcoding delays.
3. Springs vs. Easing: Different Feels for Different Moments
Two animation families. Same goal, different personality.
| Aspect | Easing | Spring |
|---|---|---|
| Duration | Fixed (duration: 400ms) | Natural (settles on its own) |
| Feel | Controlled, polished | Alive, responsive |
| Use case | Transitions, fades, choreography | Buttons, direct manipulation, playful states |
| Overshoot | None (easing curves stop exactly) | Yes (overshoots, bounces, settles) |
The table tells you the difference. It cannot tell you where the line is between a spring that feels alive and one that feels broken, and no table can, because that judgment is yours. Move the sliders until you find it.
Two things are worth finding for yourself. Damping below about 8 produces the wobble that reads as a glitch rather than as physics. And on the easing row, somewhere past 800ms the motion stops feeling deliberate and starts feeling like the interface is thinking.
Easing: Timing functions
<motion.box initial={{ x: 0 }} animate={{ x: 100 }} transition={{ duration: 0.4, ease: "easeInOut" }}/>Built-in eases: linear, easeIn, easeOut, easeInOut, anticipate. Or define your own cubic-bezier:
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1.0], // Matches CSS ease}}When to use easing:
- Page transitions (fade to next screen)
- Orchestrated sequences (cards cascading in)
- Subtle state changes (hover, focus)
- Any animation you're choreographing with code
Springs: Physics-based
<motion.button whileHover={{ scale: 1.1 }} transition={{ type: "spring", stiffness: 300, damping: 20 }}/>Springs bypass duration entirely. Framer Motion calculates when they settle based on physics:
stiffness = 200, damping = 15→ settles in ~400msWhen to use springs:
- Direct user input (clicks, taps, drags)
- Button presses and focus states
- Playful, lively interactions
- Anything that feels more "alive" than "designed"
The rule of thumb: Easing is for narrative (the designer is telling a story). Springs are for response (the user is driving the interaction).
4. Gestures: Responding to the User
Motion isn't just about states, it's about input. Framer Motion captures gestures.
Hover & tap
<motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} transition={{ type: "spring", stiffness: 300 }}> Press me</motion.button>whileHover, animates when hoveredwhileTap, animates when pressed (touches or clicks)whileFocus, animates when focused (keyboard navigation)
The button grows slightly on hover, shrinks on press. Instant visual feedback that the interface is listening.
Drag & constraints
<motion.div drag dragConstraints={{ left: -100, right: 100, top: -50, bottom: 50 }} onDragEnd={(e, info) => { if (info.offset.x > 50) { // Swiped right } }}> Drag me</motion.div>drag="x" constrains to horizontal. drag="y" to vertical. drag allows both. dragConstraints are the bounds, the element stops at the edges.
5. Layout & Page Transitions: Moving Between States
The hardest motion problem: transitioning entire screens without feeling jarring.
Shared layout animations
import { AnimatePresence, motion } from "framer-motion"; export function TabPanel() { const [tab, setTab] = useState("home"); return ( <> <motion.button onClick={() => setTab("home")} style={{ borderBottom: tab === "home" ? "2px solid blue" : "none", }} > Home </motion.button> <motion.button onClick={() => setTab("about")} style={{ borderBottom: tab === "about" ? "2px solid blue" : "none", }} > About </motion.button> <AnimatePresence mode="wait"> {tab === "home" && ( <motion.div key="home" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} > <p>Home content</p> </motion.div> )} {tab === "about" && ( <motion.div key="about" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} > <p>About content</p> </motion.div> )} </AnimatePresence> </> );}AnimatePresence with mode="wait" ensures the old tab fades out before the new one fades in. No overlap, no jarring switches.
Slide transitions (more sophisticated)
<AnimatePresence mode="wait"> {tab === "home" && ( <motion.div key="home" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -100, opacity: 0 }} transition={{ duration: 0.3 }} > <p>Home content</p> </motion.div> )} {tab === "about" && ( <motion.div key="about" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -100, opacity: 0 }} transition={{ duration: 0.3 }} > <p>About content</p> </motion.div> )}</AnimatePresence>The incoming tab slides in from the right (x: 100), the outgoing slides left (x: -100). Creates a directional sense of movement.
Important: Each component needs a unique key so Framer Motion can track mount/unmount separately.
6. Accessibility: prefers-reduced-motion is Non-Negotiable
Motion can cause dizziness, nausea, or cognitive overload in users with vestibular disorders or motion sensitivity. The prefers-reduced-motion media query is not a nice-to-have. It's required.
CSS approach
@media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms ; animation-iteration-count: 1 ; transition-duration: 0.01ms ; }}This brutally disables all animations for users with the preference set. But it's a sledgehammer. Better to be intentional.
Framer Motion + hook
import { useEffect, useState } from "react"; export function useReducedMotion() { const [reducedMotion, setReducedMotion] = useState(false); useEffect(() => { const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); setReducedMotion(mediaQuery.matches); const listener = (e: MediaQueryListEvent) => setReducedMotion(e.matches); mediaQuery.addEventListener("change", listener); return () => mediaQuery.removeEventListener("change", listener); }, []); return reducedMotion;}Now use it:
export function AnimatedCard() { const reducedMotion = useReducedMotion(); return ( <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={ reducedMotion ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 30 } } > <p>This respects your motion preference.</p> </motion.div> );}When prefers-reduced-motion: reduce is set:
duration: 0= instant state change (no animation)- Component still mounts and updates, just without the motion
Not this:
if (reducedMotion) { return <div>No animation version</div>;}return <motion.div>...</motion.div>;This creates two code paths. One will get stale. Instead, use the same component with conditional transitions.
Testing
On macOS: System Preferences → Accessibility → Display → Reduce motion
On Windows: Settings → Ease of Access → Display → Show animations
In browsers: DevTools → Rendering → Emulate CSS media feature prefers-reduced-motion
Always test both paths. Motion and reduced-motion must feel equally intentional and complete.
7. Restraint: When Not to Animate
This is the hardest skill. Many junior designers animate everything because they can. The masters know when to be silent.
The questions to ask
1. Does this motion communicate something the user couldn't understand without it?
✅ A button shrinks when pressed → "I felt that click" (confirms input) ❌ A card fades in slowly → "Nice" (doesn't add information)
2. Is this motion faster than the user's intent?
✅ A spring bounce settles in 300ms → feels responsive ❌ A page transition takes 1 second → feels sluggish
3. Does the user have to wait for the animation to proceed?
✅ A modal fades in 300ms, then the user can interact → acceptable ❌ A page transition animates 600ms before content is available → blocking
4. Will this animation run 100 times today or once?
✅ A button click: runs many times, so restraint is critical ❌ An onboarding entrance: runs once, so drama is okay
Real examples
Don't animate: State toggle that feels instant
<motion.div animate={{ opacity: 1 }} transition={{ duration: 0 }}> {isOpen ? "Open" : "Closed"}</motion.div>No animation. State changes instantly. Clarity > motion.
Animate: Button feedback (user-triggered)
<motion.button whileTap={{ scale: 0.95 }} transition={{ type: "spring", stiffness: 400, damping: 30 }}> Click</motion.button>The press is user-triggered, so immediate visual feedback is worth animating.
Animate with restraint: Loading indicator
<motion.div animate={{ rotate: 360 }} transition={{ duration: 2, repeat: Infinity, ease: "linear" }}> Loading...</motion.div>The spin communicates "something is happening." Keep it to 2 seconds per rotation, any slower and it feels broken, faster and it feels frantic.
Don't animate: Non-interactive state change
<motion.div style={{ opacity: data ? 1 : 0 }}> {data ? "Loaded" : "Empty"}</motion.div>Data arrived (the app decided, not the user). No animation needed. Just show the state.
The motion hierarchy
1. User input (clicks, taps, drags) → Animate immediately (springs)2. App response (loading, error, success) → Subtle animation (200–400ms easing)3. State (opened, closed) → No animation, instant4. Entrance/exit (onboarding, modals) → Drama OK (400–600ms)5. Decorative → Don't do thisBuild: Animated Onboarding Flow
You'll design and build a 4-screen onboarding with a real reduced-motion path.
Spec
- 1Welcome screen, Hero text, fade in, CTA button
- 2Feature 1, Icon + description, slide in from left, staggered
- 3Feature 2, Icon + description, slide in from right, staggered
- 4Final screen, Call-to-action, spring entrance, focus state
Each screen transitions via fade + slide. Buttons respond to clicks with spring bounces. Skip buttons exist to jump to the end. The entire flow respects prefers-reduced-motion.
Starter code
"use client"; import { useState } from "react";import { AnimatePresence, motion } from "framer-motion";import { useReducedMotion } from "./useReducedMotion"; export function OnboardingFlow() { const [step, setStep] = useState(0); const [completed, setCompleted] = useState(false); const reducedMotion = useReducedMotion(); const steps = [ { title: "Welcome", subtitle: "Start your journey", color: "bg-blue-600", }, { title: "Feature 1", subtitle: "Create with ease", color: "bg-purple-600", }, { title: "Feature 2", subtitle: "Share instantly", color: "bg-pink-600", }, { title: "Get started", subtitle: "Ready?", color: "bg-green-600", }, ]; const handleNext = () => { if (step < steps.length - 1) { setStep(step + 1); } else { setCompleted(true); } }; const handleSkip = () => { setStep(steps.length - 1); }; if (completed) { return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="flex items-center justify-center h-screen bg-gray-900 text-white" > <h1 className="text-4xl font-bold">You're in!</h1> </motion.div> ); } return ( <div className="relative h-screen overflow-hidden"> <AnimatePresence mode="wait"> <motion.div key={step} initial={{ opacity: 0, x: step % 2 === 0 ? 100 : -100 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: step % 2 === 0 ? -100 : 100 }} transition={{ duration: reducedMotion ? 0 : 0.4, ease: "easeInOut", }} className={`absolute inset-0 ${steps[step].color} flex flex-col items-center justify-center p-8`} > <motion.h1 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: reducedMotion ? 0 : 0.5, delay: reducedMotion ? 0 : 0.1, }} className="text-5xl font-bold text-white mb-4" > {steps[step].title} </motion.h1> <motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: reducedMotion ? 0 : 0.5, delay: reducedMotion ? 0 : 0.2, }} className="text-xl text-white/80 mb-8" > {steps[step].subtitle} </motion.p> <div className="flex gap-4 mt-8"> <motion.button whileTap={{ scale: reducedMotion ? 1 : 0.95 }} transition={{ type: reducedMotion ? "tween" : "spring", stiffness: 300, damping: 20, }} onClick={handleNext} className="px-8 py-3 bg-white text-blue-600 font-semibold rounded-lg" > {step === steps.length - 1 ? "Complete" : "Next"} </motion.button> {step < steps.length - 1 && ( <motion.button whileTap={{ scale: reducedMotion ? 1 : 0.95 }} transition={{ type: reducedMotion ? "tween" : "spring", stiffness: 300, damping: 20, }} onClick={handleSkip} className="px-8 py-3 bg-white/20 text-white font-semibold rounded-lg" > Skip </motion.button> )} </div> </motion.div> </AnimatePresence> <div className="absolute bottom-8 left-0 right-0 flex justify-center gap-2"> {steps.map((_, i) => ( <motion.div key={i} className={`w-3 h-3 rounded-full ${ i === step ? "bg-white" : "bg-white/40" }`} animate={{ scale: i === step ? 1.2 : 1 }} transition={{ type: reducedMotion ? "tween" : "spring", stiffness: 300, }} /> ))} </div> </div> );}What this does
- Screen transitions: Fade + slide (incoming from right if
step % 2 === 0, from left otherwise). Outgoing goes opposite direction. Creates a directional flow. - Text stagger: Title and subtitle fade in with a slight delay, creating rhythm without
staggerChildren. - Button feedback:
whileTapscales to 0.95 on press, instant tactile feedback. - Dots: Progress indicator animates scale as the active step changes.
- Reduced motion: When enabled, all transitions use
duration: 0or remove the spring entirely, falling back to instant or linear tween. The UI structure is identical, no branching, no two code paths.
Extend it
- Add swipe gestures:
drag="x"on the screen container, detect velocity withonDragEnd, advance/retreat on threshold - Add sound effects: Play a subtle "whoosh" on slide, "pop" on button click
- Add parallax: Move the title slightly faster than the subtitle as screens transition
- Remember state: Save which step the user reached to localStorage, resume on return
Checkpoint
Before shipping any motion work, verify:
Motion serves meaning
Easing & springs are chosen with intent
prefers-reduced-motion is real, not an afterthought
60fps, nothing stutters
Bonus: Craft notes
Restraint
The craft is in the calibration
Motion is polish. It's the difference between an app that feels responsive and one that feels sluggish. But polish is only felt when it's right. A spring that overshoots by 5% feels alive; 20% feels broken. A 300ms fade feels intentional; 1.5 seconds feels slow. The craft is in the millimeters, the calibration, the restraint, the knowledge of when not to move. This is what separates an app you enjoy using from one you endure.
When not to animate
The real skill. Any designer can throw motion at a problem. The masters know that silence is louder. A state that changes instantly is clearer than one that animates. A loading spinner that's too subtle won't communicate; too dramatic wastes time. The hierarchy is: user input (animate), app response (subtle), state (none), entrance (okay to be dramatic). Decorative motion is a smell. If an animation doesn't serve communication or feedback, it's clutter. Restraint is what separates craft from noise.
Further Reading
- Framer Motion docs: https://www.framer.com/motion/, especially gestures and variants
- Web Animations Working Group: https://www.w3.org/TR/web-animations-1/, the spec behind everything
- Spring physics: https://www.framer.com/motion/animation/#spring, calibration guide
- A List Apart: "Designing with Motion": https://alistapart.com/, thoughtful essays on when/why to animate
- Apple Human Interface Guidelines: Animation: https://developer.apple.com/design/human-interface-guidelines/components/, iOS/macOS baseline (they get it right)
Summary
Motion is communication. Every frame you draw has a job: respond to input, clarify state, guide attention, or tell a story. CSS transitions handle 80% of everyday work. Framer Motion handles the other 20% that needs coordination. Springs feel alive; easing feels designed. The hardest skill is knowing when to stop, when silence is louder than movement. And prefers-reduced-motion is not a feature flag; it's a requirement.
Ship motion that's intentional, accessible, and fast. Everything else is decoration.