Skill 寫法圖鑑

motion-foundations

affaan-m | 設計與前端 | 原文 | MIT

motion/react 的 token、spring 預設、無障礙與 SSR 安全基礎層

中文翻譯

---
name: motion-foundations
description: 使用 motion/react 的 React / Next.js 的 motion token、spring 預設、效能規則、裝置適配、無障礙強制規範和 SSR 安全。基礎層,其他所有 motion skill 都依賴它。在 React 或 Next.js 裡設置 motion token、spring 預設、reduced-motion 處理或 SSR 安全的動畫時使用。
tags: [motion, animation, performance, accessibility]
category: frontend
author: jeff
metadata:
  version: 1.0.0
---

# Motion Foundations

動態系統的底層。定義下游 skill(`motion-patterns`、`motion-advanced`)會繼承的
每個值、限制和規則。
開始任何動畫工作之前,先載入這個 skill。

## When to Activate

- 從零開始做任何有動畫的元件
- 設置 token、spring 預設或 easing 值
- 實作 `prefers-reduced-motion` 支援
- 除錯動畫初始狀態造成的 hydration 不一致
- 評估某個動畫到底該不該存在

## Outputs

這個 skill 會產出:

- 共用的 `motionTokens` 物件(duration、easing、distance、scale)
- 共用的 `springs` 預設對照表(5 個具名設定)
- 所有元件都使用的 `shouldAnimate()` 關卡
- 透過 `useReducedMotion` 達成、符合無障礙規範的動畫預設值
- 零 hydration 警告的 SSR 安全初始狀態

## Principles

動態至少要做到下列其中一件事,否則就該移除:

- 引導注意力
- 傳達狀態
- 保持空間連續性

回應速度永遠優先於平滑度。一個 60 fps 卻造成輸入延遲的動畫,
比沒有動畫更糟。

## Rules

這些沒有商量餘地。它們適用於系統裡的每個元件。

1. **只用 `motion/react`。** 絕對不要從 `framer-motion` import。絕對不要在同一棵樹裡混用兩者。
2. **`initial` 必須和伺服器輸出一致。** 如果伺服器渲染的是 `opacity: 1`,`initial` prop 也必須是 `opacity: 1`。沒有例外。
3. **Reduced motion 凌駕一切。** 當 `useReducedMotion()` 回傳 `true`,或 `prefersReduced` 是 `true` 時,所有 transform 都要停用。唯一允許的退路,是 ≤ 0.2 秒、只有 opacity 的淡入淡出。
4. **絕對不要對 layout 屬性做動畫。** `width`、`height`、`top`、`left`、`margin`、`padding` 禁止出現在 `animate` 裡。只能用 `transform` 和 `opacity`。
5. **所有 token 值都來自 `motionTokens`。** 元件檔案裡禁止寫死 duration 和 easing。
6. **所有 spring 設定都來自 `springs` 對照表。** 禁止內嵌 `stiffness`/`damping` 值。
7. **每個 import `motion/react` 的檔案都必須有 `"use client"`。**
8. **絕對不要在模組層級讀 `window` 或 `navigator`。** 一律用 `typeof window !== "undefined"` 防護。

## Decision Guidance

### Choosing a duration

| Token | 使用時機 |
| --------- | -------------------------------------------- |
| `instant` | Tooltip 顯示/隱藏、focus ring、徽章更新 |
| `fast` | 按鈕回饋、圖示切換、chip 切換 |
| `normal` | Modal 開啟、卡片展開、頁面元素進場 |
| `slow` | Hero 進場、整頁轉場 |
| `crawl` | 刻意的敘事感;少用 |

### Choosing a spring

| 預設 | 使用時機 |
| --------- | ------------------------------------------ |
| `snappy` | 預設的 UI:按鈕、chip、導覽項目 |
| `gentle` | 卡片、modal、面板輕柔落下 |
| `bouncy` | 俏皮的時刻:空狀態、onboarding |
| `instant` | Tooltip、popover、下拉選單 |
| `release` | 拖曳放開:自然的物理手感 |

### When to disable animation entirely

下列情況要停用(讓 `shouldAnimate()` 回傳 `false`):

- `prefersReduced` 是 `true`
- `isLowEnd` 是 `true`,而且動畫不是必要的
- 元素在畫面外,而且永遠不會進入 viewport
- 動畫純粹是裝飾,沒有任何 UX 目的

## Core Concepts

### Token system

```ts
// 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

```ts
// 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

**優先順序(由高到低):**

1. `prefers-reduced-motion: reduce`:停用所有 transform,把 opacity 轉場限制在 ≤ 0.2 秒
2. 低階裝置偵測:縮短 duration,移除非必要的動畫
3. 設計偏好:其他所有情況

動態必須平順地降級。它絕不能突然消失,
造成 layout shift 或讓人搞不清方向。

```tsx
// 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 },
  }
}
```

```css
/* globals.css */
@media (prefers-reduced-motion: reduce) {
  .motion-safe-transition  { transition: opacity 0.15s; }
  .motion-reduce-transform { transform: none !important; }
}
```

```html
<!-- Tailwind -->
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
```

### SSR / hydration safety

**規則:`initial` 必須永遠和伺服器渲染的內容一致。**

```tsx
// 錯誤:伺服器渲染 opacity:1,但 initial 寫 0,hydration 會不一致
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />

// 正確:用 AnimatePresence,或等到 client 掛載之後
"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

```tsx
// 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 防護:initial 必須和伺服器輸出一致(opacity: 1)
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])

  // 無障礙:偏好 reduced motion 時停用 transform
  const safeMotion = useSafeMotion(motionTokens.distance.md)

  // 裝置關卡:低階硬體上略過動畫
  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

這個 skill **不**涵蓋:

- UI 元件模式(按鈕、modal、stagger)→ 見 `motion-patterns`
- 拖曳、手勢、SVG、文字動畫、自訂 hook → 見 `motion-advanced`
- 純 CSS 動畫,或不透過 `motion/react` 的 Tailwind `animate-*` class
- 第三方動畫函式庫(GSAP、anime.js 等)
- 動態設計的決策(何時做動畫、要強調什麼):那是設計的事,不是程式碼的限制

## Anti-Patterns

| Anti-pattern | 違反的規則 | 修法 |
| --------------------------------------- | ------- | ------------------------------- |
| `import { motion } from "framer-motion"` | Rule 1 | 用 `motion/react` |
| SSR 元件上寫 `initial={{ opacity: 0 }}` | Rule 2 | 加掛載防護 |
| 略過 `useReducedMotion` 檢查 | Rule 3 | 用 `useSafeMotion` hook |
| `animate={{ width: "100%" }}` | Rule 4 | 改用 `scaleX` transform |
| 內嵌寫 `transition={{ duration: 0.4 }}` | Rule 5 | 用 `motionTokens.duration.normal` |
| 內嵌寫 `{ stiffness: 300, damping: 30 }` | Rule 6 | 用 `springs.snappy` |
| 缺少 `"use client"` 指令 | Rule 7 | 加在檔案最上面 |
| 在模組層級用 `navigator.hardwareConcurrency` | Rule 8 | 用 `typeof navigator !== "undefined"` 包起來 |

## Related Skills

- **`motion-patterns`**:使用這裡定義的 token 和 spring,建出按鈕、modal、stagger、頁面轉場和捲動的模式。不會重新定義任何值。
- **`motion-advanced`**:使用這裡定義的 token 和 spring,做拖曳、SVG、文字和手勢模式。在這個基礎上加上 `useAnimate` 序列和自訂 hook。

英文原文

---
name: motion-foundations
description: 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. Use when setting up motion tokens, spring presets, reduced-motion handling, or SSR-safe animation in React or Next.js.
tags: [motion, animation, performance, accessibility]
category: frontend
author: jeff
metadata:
  version: 1.0.0
---

# 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.

1. **Use `motion/react` only.** Never import from `framer-motion`. Never mix the two in the same tree.
2. **`initial` must match server output.** If the server renders `opacity: 1`, the `initial` prop must also be `opacity: 1`. No exceptions.
3. **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.
4. **Never animate layout properties.** `width`, `height`, `top`, `left`, `margin`, `padding` are banned from `animate`. Use `transform` and `opacity` only.
5. **All token values come from `motionTokens`.** Hardcoded durations and easings in component files are forbidden.
6. **All spring configs come from the `springs` map.** Inline `stiffness`/`damping` values are forbidden.
7. **`"use client"` is required** on every file that imports from `motion/react`.
8. **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

```ts
// 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

```ts
// 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):**

1. `prefers-reduced-motion: reduce` — disables all transforms, limits opacity transitions to ≤ 0.2s
2. Low-end device detection — reduces duration, removes non-essential animations
3. Design preference — everything else

Motion must degrade gracefully. It must never disappear abruptly in a way
that causes layout shift or confuses orientation.

```tsx
// 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 },
  }
}
```

```css
/* globals.css */
@media (prefers-reduced-motion: reduce) {
  .motion-safe-transition  { transition: opacity 0.15s; }
  .motion-reduce-transform { transform: none !important; }
}
```

```html
<!-- Tailwind -->
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
```

### SSR / hydration safety

**Rule: `initial` must always match what the server renders.**

```tsx
// 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

```tsx
// 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.