運動基礎 是做什麼的?
運動系統的基礎層。定義所有下游技能(motion-patterns、motion-advanced)繼承的每個值、約束和規則。請在任何動畫工作開始前載入此技能。
何時啟用
- 從頭開始建立任何動畫元件
- 設定令牌、彈簧預設或緩動值
- 實作
prefers-reduced-motion支援 - 除錯因動畫初始狀態導致的水合不一致
- 評估動畫是否應該存在
產出
此技能產出:
- 一個共享的
motionTokens物件(持續時間、緩動、距離、縮放) - 一個共享的
springs預設映射(5 個命名配置) - 所有元件使用的
shouldAnimate()閘道 - 透過
useReducedMotion實現無障礙合規的動畫預設值 - 零水合警告的 SSR 安全初始狀態
原則
動畫必須至少執行以下其中一項,否則必須被移除:
- 引導注意力
- 溝通狀態
- 保持空間連續性
回應性始終優先於流暢度。一個導致輸入延遲的 60 fps 動畫比沒有動畫更糟。
規則
這些是不可協商的。它們適用於系統中的每個元件。
- 僅使用
motion/react。永遠不要從framer-motion匯入。切勿在相同元件樹中混用兩者。 initial必須與伺服器輸出相符。如果伺服器渲染opacity: 1,則initialprop 也必須是opacity: 1。沒有例外。- 減少動作覆蓋一切。當
useReducedMotion()回傳true或prefersReduced為true時,所有轉換都被停用。僅允許持續時間 ≤ 0.2s 的透明度淡化作為後備。 - 永遠不要動畫化佈局屬性。
width、height、top、left、margin、padding被禁止用於animate。僅使用transform和opacity。 - 所有令牌值都來自
motionTokens。禁止在元件檔案中使用硬編碼的持續時間和緩動。 - 所有彈簧配置都來自
springs映射。禁止使用內聯的stiffness/damping值。 - 每個從
motion/react匯入的檔案都必須有"use client"。 - 永遠不要在模組層級讀取
window或navigator。始終使用typeof window !== "undefined"進行保護。
決策指南
選擇持續時間
| 令牌 | 使用時機 |
|---|---|
instant | 工具提示顯示/隱藏、焦點環、徽章更新 |
fast | 按鈕反饋、圖示切換、標籤切換 |
normal | 模態框開啟、卡片展開、頁面元素進入 |
slow | 英雄區進入、全頁面過渡 |
crawl | 刻意敍事;謹慎使用 |
選擇彈簧
| 預設 | 使用時機 |
|---|---|
snappy | 預設 UI — 按鈕、標籤、導航項目 |
gentle | 卡片、模態框、面板輕柔落地 |
bouncy | 俏皮的時刻 — 空狀態、入門導覽 |
instant | 工具提示、彈出視窗、下拉選單 |
release | 拖曳釋放 — 自然的物理感覺 |
何時完全停用動畫
在以下情況下停用(讓 shouldAnimate() 回傳 false):
prefersReduced為trueisLowEnd為true且動畫非必要- 元素在畫面外且永遠不會進入視窗
- 動畫純粹是裝飾性,沒有 UX 目的
核心概念
令牌系統
// 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 },
}
執行時期旗標
// 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
},
}
無障礙
優先順序(從高到低):
prefers-reduced-motion: reduce— 停用所有轉換,將透明度過渡限制在 ≤ 0.2s- 低階裝置檢測 — 縮短持續時間,移除非必要動畫
- 設計偏好 — 其他所有情況
動畫必須優雅降級。它絕不能突然消失,導致佈局位移或使使用者迷失方向。
// 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 / 水合安全性
規則:initial 必須始終與伺服器渲染的內容相符。
// 錯誤 — 伺服器渲染 opacity:1 但 initial 設為 0 → 水合不一致
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
// 正確 — 使用 AnimatePresence 或延遲到用戶端掛載
"use client"
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
<motion.div
initial={{ opacity: mounted ? 0 : 1 }}
animate={{ opacity: 1 }}
/>
程式碼範例
端到端:令牌 + 彈簧 + 無障礙 + SSR 保護
// 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), [])
// 無障礙 — 當偏好減少動作時停用轉換
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>
)
}
約束 / 非目標
此技能不涵蓋:
- UI 元件模式(按鈕、模態框、階梯動畫)→ 請參閱
motion-patterns - 拖曳、手勢、SVG、文字動畫、自訂 Hook → 請參閱
motion-advanced - 純 CSS 動畫或沒有
motion/react的 Tailwindanimate-*類別 - 第三方動畫庫(GSAP、anime.js 等)
- 動畫設計決策(何時動畫、強調什麼)— 這是設計問題,不是程式碼約束
反模式
| 反模式 | 違反的規則 | 修正 |
|---|---|---|
import { motion } from "framer-motion" | 規則 1 | 使用 motion/react |
initial={{ opacity: 0 }} 用於 SSR 元件 | 規則 2 | 添加掛載保護 |
跳過 useReducedMotion 檢查 | 規則 3 | 使用 useSafeMotion hook |
animate={{ width: "100%" }} | 規則 4 | 改用 scaleX 轉換 |
內聯 transition={{ duration: 0.4 }} | 規則 5 | 使用 motionTokens.duration.normal |
內聯 { stiffness: 300, damping: 30 } | 規則 6 | 使用 springs.snappy |
缺少 "use client" 指令 | 規則 7 | 添加到檔案頂部 |
在模組層級使用 navigator.hardwareConcurrency | 規則 8 | 包裹在 typeof navigator !== "undefined" 中 |
相關技能
motion-patterns— 使用此處定義的令牌和彈簧來建構按鈕、模態框、階梯動畫、頁面過渡和捲動模式。不重新定義任何值。motion-advanced— 使用此處定義的令牌和彈簧來實現拖曳、SVG、文字和手勢模式。在此基礎上添加useAnimate序列和自訂 Hook。