Motion Foundations
The base layer of the motion system. Defines every value, constraint, and
rule that downstream skills (motion-patterns, motion-advanced) inherit.
Load this skill before any animation work begins.
When to Activate
- Starting any animated component from scratch
- Setting up tokens, spring presets, or easing values
- Implementing
prefers-reduced-motion support
- Debugging hydration mismatches from animation initial states
- Evaluating whether an animation should exist at all
Outputs
This skill produces:
- A shared
motionTokens object (duration, easing, distance, scale)
- A shared
springs preset map (5 named configs)
- A
shouldAnimate() gate used by all components
- Accessibility-compliant animation defaults via
useReducedMotion
- SSR-safe initial states with zero hydration warnings
Principles
Motion must do at least one of the following or it must be removed:
- Guide attention
- Communicate state
- Preserve spatial continuity
Responsiveness always outranks smoothness. A 60 fps animation that causes
input delay is worse than no animation.
Rules
These are non-negotiable. They apply to every component in the system.
- Use
motion/react only. Never import from framer-motion. Never mix the two in the same tree.
initial must match server output. If the server renders opacity: 1, the initial prop must also be opacity: 1. No exceptions.
- Reduced motion overrides everything. When
useReducedMotion() returns true or prefersReduced is true, all transforms are disabled. Opacity-only fades at ≤ 0.2s are the only permitted fallback.
- Never animate layout properties.
width, height, top, left, margin, padding are banned from animate. Use transform and opacity only.
- All token values come from
motionTokens. Hardcoded durations and easings in component files are forbidden.
- All spring configs come from the
springs map. Inline stiffness/damping values are forbidden.
"use client" is required on every file that imports from motion/react.
- Never read
window or navigator at module level. Always guard with typeof window !== "undefined".
Decision Guidance
Choosing a duration
| Token |
Use when |
instant |
Tooltip show/hide, focus ring, badge update |
fast |
Button feedback, icon swap, chip toggle |
normal |
Modal open, card expand, page element enter |
slow |
Hero entrance, full-page transition |
crawl |
Deliberate storytelling; use sparingly |
Choosing a spring
| Preset |
Use when |
snappy |
Default UI — buttons, chips, nav items |
gentle |
Cards, modals, panels landing softly |
bouncy |
Playful moments — empty states, onboarding |
instant |
Tooltips, popovers, dropdowns |
release |
Drag release — natural physics feel |
When to disable animation entirely
Disable (make shouldAnimate() return false) when:
prefersReduced is true
isLowEnd is true and the animation is non-essential
- The element is off-screen and will never enter the viewport
- The animation is purely decorative with no UX purpose
Core Concepts
Token system
// lib/motion-tokens.ts
export const motionTokens = {
duration: {
instant: 0.08,
fast: 0.18,
normal: 0.35,
slow: 0.6,
crawl: 1.0,
},
easing: {
smooth: [0.22, 1, 0.36, 1],
sharp: [0.4, 0, 0.2, 1],
bounce: [0.34, 1.56, 0.64, 1],
linear: [0, 0, 1, 1],
},
distance: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 48,
},
scale: {
subtle: 0.98,
press: 0.95,
pop: 1.04,
},
}
export const springs = {
snappy: { type: "spring", stiffness: 300, damping: 30 },
gentle: { type: "spring", stiffness: 120, damping: 14 },
bouncy: { type: "spring", stiffness: 400, damping: 10 },
instant: { type: "spring", stiffness: 600, damping: 35 },
release: { type: "spring", stiffness: 200, damping: 20, restDelta: 0.001 },
}
Runtime flags
// lib/motion-config.ts
export const motionConfig = {
isLowEnd() {
return (
typeof navigator !== "undefined" &&
navigator.hardwareConcurrency <= 4
)
},
prefersReduced() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
},
shouldAnimate({ essential = false } = {}) {
if (this.prefersReduced()) return false
if (!essential && this.isLowEnd()) return false
return true
},
duration() {
return this.isLowEnd() || this.prefersReduced()
? motionTokens.duration.instant
: motionTokens.duration.normal
},
}
Accessibility
Priority order (highest to lowest):
prefers-reduced-motion: reduce — disables all transforms, limits opacity transitions to ≤ 0.2s
- Low-end device detection — reduces duration, removes non-essential animations
- Design preference — everything else
Motion must degrade gracefully. It must never disappear abruptly in a way
that causes layout shift or confuses orientation.
// hooks/use-reduced-motion.tsx
"use client"
import { useReducedMotion } from "motion/react"
export function useSafeMotion(fullY: number = 16) {
const reduce = useReducedMotion()
return {
initial: { opacity: 0, y: reduce ? 0 : fullY },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: reduce ? 0 : -fullY },
}
}
/* globals.css */
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition { transition: opacity 0.15s; }
.motion-reduce-transform { transform: none !important; }
}
<!-- Tailwind -->
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
SSR / hydration safety
Rule: initial must always match what the server renders.
// WRONG — server renders opacity:1 but initial says 0 → hydration mismatch
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
// CORRECT — use AnimatePresence or defer to client mount
"use client"
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
<motion.div
initial={{ opacity: mounted ? 0 : 1 }}
animate={{ opacity: 1 }}
/>
Code Examples
End-to-end: tokens + springs + accessibility + SSR guard
// components/fade-in-card.tsx
"use client"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
import { motionConfig } from "@/lib/motion-config"
interface FadeInCardProps {
children: React.ReactNode
delay?: number
}
export function FadeInCard({ children, delay = 0 }: FadeInCardProps) {
// SSR guard — initial must match server output (opacity: 1)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// Accessibility — disables transform when reduced motion is preferred
const safeMotion = useSafeMotion(motionTokens.distance.md)
// Device gate — skip animation on low-end hardware
if (!motionConfig.shouldAnimate() || !mounted) {
return <div>{children}</div>
}
return (
<motion.div
initial={safeMotion.initial}
animate={safeMotion.animate}
exit={safeMotion.exit}
transition={{
...springs.gentle,
delay,
}}
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
>
{children}
</motion.div>
)
}
Constraints / Non-Goals
This skill does not cover:
- UI component patterns (button, modal, stagger) → see
motion-patterns
- Drag, gestures, SVG, text animations, custom hooks → see
motion-advanced
- CSS-only animations or Tailwind
animate-* classes without motion/react
- Third-party animation libraries (GSAP, anime.js, etc.)
- Motion design decisions (when to animate, what to emphasize) — that is a design concern, not a code constraint
Anti-Patterns
| Anti-pattern |
Rule violated |
Fix |
import { motion } from "framer-motion" |
Rule 1 |
Use motion/react |
initial={{ opacity: 0 }} on SSR component |
Rule 2 |
Add mount guard |
Skipping useReducedMotion check |
Rule 3 |
Use useSafeMotion hook |
animate={{ width: "100%" }} |
Rule 4 |
Use scaleX transform instead |
transition={{ duration: 0.4 }} inline |
Rule 5 |
Use motionTokens.duration.normal |
{ stiffness: 300, damping: 30 } inline |
Rule 6 |
Use springs.snappy |
Missing "use client" directive |
Rule 7 |
Add to top of file |
navigator.hardwareConcurrency at module level |
Rule 8 |
Wrap in typeof navigator !== "undefined" |
Related Skills
motion-patterns — consumes tokens and springs defined here to build button, modal, stagger, page transition, and scroll patterns. Does not redefine any values.
motion-advanced — consumes tokens and springs defined here for drag, SVG, text, and gesture patterns. Adds useAnimate sequences and custom hooks on top of this foundation.
1---
2name: motion-foundations
3description: Motion tokens, spring presets, performance rules, device adaptation, accessibility enforcement, and SSR safety for React / Next.js using motion/react. Foundation layer — all other motion skills depend on this.
4version: 1.0
5tags: [motion, animation, performance, accessibility]
6category: frontend
7author: jeff
8origin: adapted:affaan-m/ECC
9license: MIT © ECC contributors
10
11---
12
13# Motion Foundations
14
15The base layer of the motion system. Defines every value, constraint, and
16rule that downstream skills (`motion-patterns`, `motion-advanced`) inherit.
17Load this skill before any animation work begins.
18
19## When to Activate
20
21- Starting any animated component from scratch
22- Setting up tokens, spring presets, or easing values
23- Implementing `prefers-reduced-motion` support
24- Debugging hydration mismatches from animation initial states
25- Evaluating whether an animation should exist at all
26
27## Outputs
28
29This skill produces:
30
31- A shared `motionTokens` object (duration, easing, distance, scale)
32- A shared `springs` preset map (5 named configs)
33- A `shouldAnimate()` gate used by all components
34- Accessibility-compliant animation defaults via `useReducedMotion`
35- SSR-safe initial states with zero hydration warnings
36
37## Principles
38
39Motion must do at least one of the following or it must be removed:
40
41- Guide attention
42- Communicate state
43- Preserve spatial continuity
44
45Responsiveness always outranks smoothness. A 60 fps animation that causes
46input delay is worse than no animation.
47
48## Rules
49
50These are non-negotiable. They apply to every component in the system.
51
521. **Use `motion/react` only.** Never import from `framer-motion`. Never mix the two in the same tree.
532. **`initial` must match server output.** If the server renders `opacity: 1`, the `initial` prop must also be `opacity: 1`. No exceptions.
543. **Reduced motion overrides everything.** When `useReducedMotion()` returns `true` or `prefersReduced` is `true`, all transforms are disabled. Opacity-only fades at ≤ 0.2s are the only permitted fallback.
554. **Never animate layout properties.** `width`, `height`, `top`, `left`, `margin`, `padding` are banned from `animate`. Use `transform` and `opacity` only.
565. **All token values come from `motionTokens`.** Hardcoded durations and easings in component files are forbidden.
576. **All spring configs come from the `springs` map.** Inline `stiffness`/`damping` values are forbidden.
587. **`"use client"` is required** on every file that imports from `motion/react`.
598. **Never read `window` or `navigator` at module level.** Always guard with `typeof window !== "undefined"`.
60
61## Decision Guidance
62
63### Choosing a duration
64
65| Token | Use when |
66| --------- | -------------------------------------------- |
67| `instant` | Tooltip show/hide, focus ring, badge update |
68| `fast` | Button feedback, icon swap, chip toggle |
69| `normal` | Modal open, card expand, page element enter |
70| `slow` | Hero entrance, full-page transition |
71| `crawl` | Deliberate storytelling; use sparingly |
72
73### Choosing a spring
74
75| Preset | Use when |
76| --------- | ------------------------------------------ |
77| `snappy` | Default UI — buttons, chips, nav items |
78| `gentle` | Cards, modals, panels landing softly |
79| `bouncy` | Playful moments — empty states, onboarding |
80| `instant` | Tooltips, popovers, dropdowns |
81| `release` | Drag release — natural physics feel |
82
83### When to disable animation entirely
84
85Disable (make `shouldAnimate()` return `false`) when:
86
87- `prefersReduced` is `true`
88- `isLowEnd` is `true` and the animation is non-essential
89- The element is off-screen and will never enter the viewport
90- The animation is purely decorative with no UX purpose
91
92## Core Concepts
93
94### Token system
95
96```ts
97// lib/motion-tokens.ts
98export const motionTokens = {
99 duration: {
100 instant: 0.08,
101 fast: 0.18,
102 normal: 0.35,
103 slow: 0.6,
104 crawl: 1.0,
105 },
106 easing: {
107 smooth: [0.22, 1, 0.36, 1],
108 sharp: [0.4, 0, 0.2, 1],
109 bounce: [0.34, 1.56, 0.64, 1],
110 linear: [0, 0, 1, 1],
111 },
112 distance: {
113 xs: 4,
114 sm: 8,
115 md: 16,
116 lg: 24,
117 xl: 48,
118 },
119 scale: {
120 subtle: 0.98,
121 press: 0.95,
122 pop: 1.04,
123 },
124}
125
126export const springs = {
127 snappy: { type: "spring", stiffness: 300, damping: 30 },
128 gentle: { type: "spring", stiffness: 120, damping: 14 },
129 bouncy: { type: "spring", stiffness: 400, damping: 10 },
130 instant: { type: "spring", stiffness: 600, damping: 35 },
131 release: { type: "spring", stiffness: 200, damping: 20, restDelta: 0.001 },
132}
133```
134
135### Runtime flags
136
137```ts
138// lib/motion-config.ts
139export const motionConfig = {
140 isLowEnd() {
141 return (
142 typeof navigator !== "undefined" &&
143 navigator.hardwareConcurrency <= 4
144 )
145 },
146
147 prefersReduced() {
148 return (
149 typeof window !== "undefined" &&
150 window.matchMedia("(prefers-reduced-motion: reduce)").matches
151 )
152 },
153
154 shouldAnimate({ essential = false } = {}) {
155 if (this.prefersReduced()) return false
156 if (!essential && this.isLowEnd()) return false
157 return true
158 },
159
160 duration() {
161 return this.isLowEnd() || this.prefersReduced()
162 ? motionTokens.duration.instant
163 : motionTokens.duration.normal
164 },
165}
166```
167
168### Accessibility
169
170**Priority order (highest to lowest):**
171
1721. `prefers-reduced-motion: reduce` — disables all transforms, limits opacity transitions to ≤ 0.2s
1732. Low-end device detection — reduces duration, removes non-essential animations
1743. Design preference — everything else
175
176Motion must degrade gracefully. It must never disappear abruptly in a way
177that causes layout shift or confuses orientation.
178
179```tsx
180// hooks/use-reduced-motion.tsx
181"use client"
182import { useReducedMotion } from "motion/react"
183
184export function useSafeMotion(fullY: number = 16) {
185 const reduce = useReducedMotion()
186 return {
187 initial: { opacity: 0, y: reduce ? 0 : fullY },
188 animate: { opacity: 1, y: 0 },
189 exit: { opacity: 0, y: reduce ? 0 : -fullY },
190 }
191}
192```
193
194```css
195/* globals.css */
196@media (prefers-reduced-motion: reduce) {
197 .motion-safe-transition { transition: opacity 0.15s; }
198 .motion-reduce-transform { transform: none !important; }
199}
200```
201
202```html
203<!-- Tailwind -->
204<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
205```
206
207### SSR / hydration safety
208
209**Rule: `initial` must always match what the server renders.**
210
211```tsx
212// WRONG — server renders opacity:1 but initial says 0 → hydration mismatch
213<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
214
215// CORRECT — use AnimatePresence or defer to client mount
216"use client"
217const [mounted, setMounted] = useState(false)
218useEffect(() => setMounted(true), [])
219
220<motion.div
221 initial={{ opacity: mounted ? 0 : 1 }}
222 animate={{ opacity: 1 }}
223/>
224```
225
226## Code Examples
227
228### End-to-end: tokens + springs + accessibility + SSR guard
229
230```tsx
231// components/fade-in-card.tsx
232"use client"
233
234import { useState, useEffect } from "react"
235import { motion } from "motion/react"
236import { motionTokens, springs } from "@/lib/motion-tokens"
237import { useSafeMotion } from "@/hooks/use-reduced-motion"
238import { motionConfig } from "@/lib/motion-config"
239
240interface FadeInCardProps {
241 children: React.ReactNode
242 delay?: number
243}
244
245export function FadeInCard({ children, delay = 0 }: FadeInCardProps) {
246 // SSR guard — initial must match server output (opacity: 1)
247 const [mounted, setMounted] = useState(false)
248 useEffect(() => setMounted(true), [])
249
250 // Accessibility — disables transform when reduced motion is preferred
251 const safeMotion = useSafeMotion(motionTokens.distance.md)
252
253 // Device gate — skip animation on low-end hardware
254 if (!motionConfig.shouldAnimate() || !mounted) {
255 return <div>{children}</div>
256 }
257
258 return (
259 <motion.div
260 initial={safeMotion.initial}
261 animate={safeMotion.animate}
262 exit={safeMotion.exit}
263 transition={{
264 ...springs.gentle,
265 delay,
266 }}
267 whileHover={{ scale: motionTokens.scale.pop }}
268 whileTap={{ scale: motionTokens.scale.press }}
269 >
270 {children}
271 </motion.div>
272 )
273}
274```
275
276## Constraints / Non-Goals
277
278This skill does **not** cover:
279
280- UI component patterns (button, modal, stagger) → see `motion-patterns`
281- Drag, gestures, SVG, text animations, custom hooks → see `motion-advanced`
282- CSS-only animations or Tailwind `animate-*` classes without `motion/react`
283- Third-party animation libraries (GSAP, anime.js, etc.)
284- Motion design decisions (when to animate, what to emphasize) — that is a design concern, not a code constraint
285
286## Anti-Patterns
287
288| Anti-pattern | Rule violated | Fix |
289| --------------------------------------- | ------- | ------------------------------- |
290| `import { motion } from "framer-motion"` | Rule 1 | Use `motion/react` |
291| `initial={{ opacity: 0 }}` on SSR component | Rule 2 | Add mount guard |
292| Skipping `useReducedMotion` check | Rule 3 | Use `useSafeMotion` hook |
293| `animate={{ width: "100%" }}` | Rule 4 | Use `scaleX` transform instead |
294| `transition={{ duration: 0.4 }}` inline | Rule 5 | Use `motionTokens.duration.normal` |
295| `{ stiffness: 300, damping: 30 }` inline | Rule 6 | Use `springs.snappy` |
296| Missing `"use client"` directive | Rule 7 | Add to top of file |
297| `navigator.hardwareConcurrency` at module level | Rule 8 | Wrap in `typeof navigator !== "undefined"` |
298
299## Related Skills
300
301- **`motion-patterns`** — consumes tokens and springs defined here to build button, modal, stagger, page transition, and scroll patterns. Does not redefine any values.
302- **`motion-advanced`** — consumes tokens and springs defined here for drag, SVG, text, and gesture patterns. Adds `useAnimate` sequences and custom hooks on top of this foundation.