Curriculum1 of 13
+ Module 12

Motion & Interaction Engineering

Build motion that communicates, and know when to leave it out.

Phase 2 · Design engineer core · 13 sections · about 12 minutes

+ 01 / 13

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.


+ 02 / 13

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

+ CSS
1button {
2 background-color: #3b82f6;
3 transition: background-color 0.3s ease-out;
4}
5 
6button:hover {
7 background-color: #2563eb;
8}

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 cascades
  • transition-property, what changes (use specific properties, not all)

Avoid transition: all

+ CSS
1/* Bad */
2button {
3 transition: all 0.3s ease-out;
4}
5 
6/* Good */
7button {
8 transition:
9 background-color 0.3s ease-out,
10 transform 0.2s ease-out;
11}

all couples animations you don't intend to couple. A border-width change shouldn't animate alongside opacity. Specificity is control.

Animations: Orchestrated keyframes

+ CSS
1@keyframes slideIn {
2 from {
3 opacity: 0;
4 transform: translateX(-20px);
5 }
6 to {
7 opacity: 1;
8 transform: translateX(0);
9 }
10}
11 
12.card {
13 animation: slideIn 0.4s ease-out;
14 animation-fill-mode: forwards;
15}

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.


+ 03 / 13

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

+ Terminal
1npm install framer-motion

Wrap your component tree in AnimatePresence to handle enter/exit animations when components mount and unmount.

Basic motion component

+ TSX
1import { motion } from "framer-motion";
2 
3export function FadeInCard() {
4 return (
5 <motion.div
6 initial={{ opacity: 0 }}
7 animate={{ opacity: 1 }}
8 transition={{ duration: 0.5 }}
9 >
10 <p>This fades in on mount.</p>
11 </motion.div>
12 );
13}
  • 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

+ TSX
1<motion.button
2 initial={{ scale: 0.95, opacity: 0 }}
3 animate={{ scale: 1, opacity: 1 }}
4 transition={{ type: "spring", stiffness: 200, damping: 15 }}
5>
6 Click me
7</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

+ TSX
1const containerVariants = {
2 hidden: { opacity: 0 },
3 visible: {
4 opacity: 1,
5 transition: {
6 staggerChildren: 0.1,
7 delayChildren: 0.2,
8 },
9 },
10};
11 
12const itemVariants = {
13 hidden: { opacity: 0, y: 10 },
14 visible: { opacity: 1, y: 0 },
15};
16 
17export function CardList() {
18 return (
19 <motion.div variants={containerVariants} initial="hidden" animate="visible">
20 {cards.map((card) => (
21 <motion.div key={card.id} variants={itemVariants}>
22 {card.title}
23 </motion.div>
24 ))}
25 </motion.div>
26 );
27}

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.


+ 04 / 13

3. Springs vs. Easing: Different Feels for Different Moments

Two animation families. Same goal, different personality.

AspectEasingSpring
DurationFixed (duration: 400ms)Natural (settles on its own)
FeelControlled, polishedAlive, responsive
Use caseTransitions, fades, choreographyButtons, direct manipulation, playful states
OvershootNone (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.

+ Try itsettles in 467ms

Spring against easing, same distance

Springovershoot 3.5%
Easing320ms

Alive. A small overshoot reads as physical.

Intentional. This is the range most interface motion lives in.

A spring is described by physics and finds its own duration. An easing curve is described by a duration you choose. Neither is better, and they fail in different ways.

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

+ TSX
1<motion.box
2 initial={{ x: 0 }}
3 animate={{ x: 100 }}
4 transition={{ duration: 0.4, ease: "easeInOut" }}
5/>

Built-in eases: linear, easeIn, easeOut, easeInOut, anticipate. Or define your own cubic-bezier:

+ TSX
1transition={{
2 duration: 0.4,
3 ease: [0.25, 0.1, 0.25, 1.0], // Matches CSS ease
4}}

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

+ TSX
1<motion.button
2 whileHover={{ scale: 1.1 }}
3 transition={{ type: "spring", stiffness: 300, damping: 20 }}
4/>

Springs bypass duration entirely. Framer Motion calculates when they settle based on physics:

+ JavaScript
1stiffness = 200, damping = 15
2→ settles in ~400ms

When 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).


+ 05 / 13

4. Gestures: Responding to the User

Motion isn't just about states, it's about input. Framer Motion captures gestures.

Hover & tap

+ TSX
1<motion.button
2 whileHover={{ scale: 1.05 }}
3 whileTap={{ scale: 0.95 }}
4 transition={{ type: "spring", stiffness: 300 }}
5>
6 Press me
7</motion.button>
  • whileHover, animates when hovered
  • whileTap, 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

+ TSX
1<motion.div
2 drag
3 dragConstraints={{ left: -100, right: 100, top: -50, bottom: 50 }}
4 onDragEnd={(e, info) => {
5 if (info.offset.x > 50) {
6 // Swiped right
7 }
8 }}
9>
10 Drag me
11</motion.div>

drag="x" constrains to horizontal. drag="y" to vertical. drag allows both. dragConstraints are the bounds, the element stops at the edges.


+ 06 / 13

5. Layout & Page Transitions: Moving Between States

The hardest motion problem: transitioning entire screens without feeling jarring.

Shared layout animations

+ TSX
1import { AnimatePresence, motion } from "framer-motion";
2 
3export function TabPanel() {
4 const [tab, setTab] = useState("home");
5 
6 return (
7 <>
8 <motion.button
9 onClick={() => setTab("home")}
10 style={{
11 borderBottom: tab === "home" ? "2px solid blue" : "none",
12 }}
13 >
14 Home
15 </motion.button>
16 <motion.button
17 onClick={() => setTab("about")}
18 style={{
19 borderBottom: tab === "about" ? "2px solid blue" : "none",
20 }}
21 >
22 About
23 </motion.button>
24 
25 <AnimatePresence mode="wait">
26 {tab === "home" && (
27 <motion.div
28 key="home"
29 initial={{ opacity: 0 }}
30 animate={{ opacity: 1 }}
31 exit={{ opacity: 0 }}
32 transition={{ duration: 0.3 }}
33 >
34 <p>Home content</p>
35 </motion.div>
36 )}
37 {tab === "about" && (
38 <motion.div
39 key="about"
40 initial={{ opacity: 0 }}
41 animate={{ opacity: 1 }}
42 exit={{ opacity: 0 }}
43 transition={{ duration: 0.3 }}
44 >
45 <p>About content</p>
46 </motion.div>
47 )}
48 </AnimatePresence>
49 </>
50 );
51}

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)

+ TSX
1<AnimatePresence mode="wait">
2 {tab === "home" && (
3 <motion.div
4 key="home"
5 initial={{ x: 100, opacity: 0 }}
6 animate={{ x: 0, opacity: 1 }}
7 exit={{ x: -100, opacity: 0 }}
8 transition={{ duration: 0.3 }}
9 >
10 <p>Home content</p>
11 </motion.div>
12 )}
13 {tab === "about" && (
14 <motion.div
15 key="about"
16 initial={{ x: 100, opacity: 0 }}
17 animate={{ x: 0, opacity: 1 }}
18 exit={{ x: -100, opacity: 0 }}
19 transition={{ duration: 0.3 }}
20 >
21 <p>About content</p>
22 </motion.div>
23 )}
24</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.


+ 07 / 13

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

+ CSS
1@media (prefers-reduced-motion: reduce) {
2 * {
3 animation-duration: 0.01ms !important;
4 animation-iteration-count: 1 !important;
5 transition-duration: 0.01ms !important;
6 }
7}

This brutally disables all animations for users with the preference set. But it's a sledgehammer. Better to be intentional.

Framer Motion + hook

+ TSX
1import { useEffect, useState } from "react";
2 
3export function useReducedMotion() {
4 const [reducedMotion, setReducedMotion] = useState(false);
5 
6 useEffect(() => {
7 const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
8 setReducedMotion(mediaQuery.matches);
9 
10 const listener = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
11 mediaQuery.addEventListener("change", listener);
12 return () => mediaQuery.removeEventListener("change", listener);
13 }, []);
14 
15 return reducedMotion;
16}

Now use it:

+ TSX
1export function AnimatedCard() {
2 const reducedMotion = useReducedMotion();
3 
4 return (
5 <motion.div
6 initial={{ opacity: 0, scale: 0.95 }}
7 animate={{ opacity: 1, scale: 1 }}
8 transition={
9 reducedMotion
10 ? { duration: 0 }
11 : { type: "spring", stiffness: 300, damping: 30 }
12 }
13 >
14 <p>This respects your motion preference.</p>
15 </motion.div>
16 );
17}

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:

+ TSX
1if (reducedMotion) {
2 return <div>No animation version</div>;
3}
4return <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.


+ 08 / 13

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

+ TSX
1<motion.div animate={{ opacity: 1 }} transition={{ duration: 0 }}>
2 {isOpen ? "Open" : "Closed"}
3</motion.div>

No animation. State changes instantly. Clarity > motion.

Animate: Button feedback (user-triggered)

+ TSX
1<motion.button
2 whileTap={{ scale: 0.95 }}
3 transition={{ type: "spring", stiffness: 400, damping: 30 }}
4>
5 Click
6</motion.button>

The press is user-triggered, so immediate visual feedback is worth animating.

Animate with restraint: Loading indicator

+ TSX
1<motion.div
2 animate={{ rotate: 360 }}
3 transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
4>
5 Loading...
6</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

+ TSX
1<motion.div style={{ opacity: data ? 1 : 0 }}>
2 {data ? "Loaded" : "Empty"}
3</motion.div>

Data arrived (the app decided, not the user). No animation needed. Just show the state.

The motion hierarchy

+ JavaScript
11. User input (clicks, taps, drags) → Animate immediately (springs)
22. App response (loading, error, success) → Subtle animation (200–400ms easing)
33. State (opened, closed) → No animation, instant
44. Entrance/exit (onboarding, modals) → Drama OK (400–600ms)
55. DecorativeDon't do this

+ 09 / 13

Build: Animated Onboarding Flow

You'll design and build a 4-screen onboarding with a real reduced-motion path.

Spec

  1. 1Welcome screen, Hero text, fade in, CTA button
  2. 2Feature 1, Icon + description, slide in from left, staggered
  3. 3Feature 2, Icon + description, slide in from right, staggered
  4. 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

+ TSX
1"use client";
2 
3import { useState } from "react";
4import { AnimatePresence, motion } from "framer-motion";
5import { useReducedMotion } from "./useReducedMotion";
6 
7export function OnboardingFlow() {
8 const [step, setStep] = useState(0);
9 const [completed, setCompleted] = useState(false);
10 const reducedMotion = useReducedMotion();
11 
12 const steps = [
13 {
14 title: "Welcome",
15 subtitle: "Start your journey",
16 color: "bg-blue-600",
17 },
18 {
19 title: "Feature 1",
20 subtitle: "Create with ease",
21 color: "bg-purple-600",
22 },
23 {
24 title: "Feature 2",
25 subtitle: "Share instantly",
26 color: "bg-pink-600",
27 },
28 {
29 title: "Get started",
30 subtitle: "Ready?",
31 color: "bg-green-600",
32 },
33 ];
34 
35 const handleNext = () => {
36 if (step < steps.length - 1) {
37 setStep(step + 1);
38 } else {
39 setCompleted(true);
40 }
41 };
42 
43 const handleSkip = () => {
44 setStep(steps.length - 1);
45 };
46 
47 if (completed) {
48 return (
49 <motion.div
50 initial={{ opacity: 0 }}
51 animate={{ opacity: 1 }}
52 transition={{ duration: 0.3 }}
53 className="flex items-center justify-center h-screen bg-gray-900 text-white"
54 >
55 <h1 className="text-4xl font-bold">You're in!</h1>
56 </motion.div>
57 );
58 }
59 
60 return (
61 <div className="relative h-screen overflow-hidden">
62 <AnimatePresence mode="wait">
63 <motion.div
64 key={step}
65 initial={{ opacity: 0, x: step % 2 === 0 ? 100 : -100 }}
66 animate={{ opacity: 1, x: 0 }}
67 exit={{ opacity: 0, x: step % 2 === 0 ? -100 : 100 }}
68 transition={{
69 duration: reducedMotion ? 0 : 0.4,
70 ease: "easeInOut",
71 }}
72 className={`absolute inset-0 ${steps[step].color} flex flex-col items-center justify-center p-8`}
73 >
74 <motion.h1
75 initial={{ opacity: 0, y: 20 }}
76 animate={{ opacity: 1, y: 0 }}
77 transition={{
78 duration: reducedMotion ? 0 : 0.5,
79 delay: reducedMotion ? 0 : 0.1,
80 }}
81 className="text-5xl font-bold text-white mb-4"
82 >
83 {steps[step].title}
84 </motion.h1>
85 
86 <motion.p
87 initial={{ opacity: 0, y: 20 }}
88 animate={{ opacity: 1, y: 0 }}
89 transition={{
90 duration: reducedMotion ? 0 : 0.5,
91 delay: reducedMotion ? 0 : 0.2,
92 }}
93 className="text-xl text-white/80 mb-8"
94 >
95 {steps[step].subtitle}
96 </motion.p>
97 
98 <div className="flex gap-4 mt-8">
99 <motion.button
100 whileTap={{ scale: reducedMotion ? 1 : 0.95 }}
101 transition={{
102 type: reducedMotion ? "tween" : "spring",
103 stiffness: 300,
104 damping: 20,
105 }}
106 onClick={handleNext}
107 className="px-8 py-3 bg-white text-blue-600 font-semibold rounded-lg"
108 >
109 {step === steps.length - 1 ? "Complete" : "Next"}
110 </motion.button>
111 
112 {step < steps.length - 1 && (
113 <motion.button
114 whileTap={{ scale: reducedMotion ? 1 : 0.95 }}
115 transition={{
116 type: reducedMotion ? "tween" : "spring",
117 stiffness: 300,
118 damping: 20,
119 }}
120 onClick={handleSkip}
121 className="px-8 py-3 bg-white/20 text-white font-semibold rounded-lg"
122 >
123 Skip
124 </motion.button>
125 )}
126 </div>
127 </motion.div>
128 </AnimatePresence>
129 
130 <div className="absolute bottom-8 left-0 right-0 flex justify-center gap-2">
131 {steps.map((_, i) => (
132 <motion.div
133 key={i}
134 className={`w-3 h-3 rounded-full ${
135 i === step ? "bg-white" : "bg-white/40"
136 }`}
137 animate={{ scale: i === step ? 1.2 : 1 }}
138 transition={{
139 type: reducedMotion ? "tween" : "spring",
140 stiffness: 300,
141 }}
142 />
143 ))}
144 </div>
145 </div>
146 );
147}

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: whileTap scales 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: 0 or 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 with onDragEnd, 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

+ 10 / 13

Checkpoint

Before shipping any motion work, verify:

Motion serves meaning

+ Checklist0 / 3

Easing & springs are chosen with intent

+ Checklist0 / 4

prefers-reduced-motion is real, not an afterthought

+ Checklist0 / 5

60fps, nothing stutters

+ Checklist0 / 4

Bonus: Craft notes

+ Checklist0 / 5

+ 11 / 13

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.


+ 12 / 13

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)

+ 13 / 13

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.

+ Up nextShipping UI, Next.js + Desktop/ElectronPreviouslyDesign Systems in Code