SKILL.md
唯讀
名稱
motion-ui
描述
適用於 React / Next.js 的正式環境級 UI 動效系統。在實作動畫、轉場效果或各類動效模式時使用。
Motion System v4.2
適用於 React / Next.js 的正式環境級 UI 動效系統。
核心專注於效能、無障礙功能與易用性——而非純粹的視覺裝飾。
何時使用
當動效符合以下目的時,請使用本動效系統:
- 引導使用者注意力(例如:新手引導、關鍵操作)
- 傳達狀態變化(載入中、成功、失敗、轉場)
- 維持空間連連貫性(版面變更、頁面導覽)
適用場景
- 互動式元件(按鈕、對話盒/Modal、選單)
- 狀態轉場(載入中 → 已載入、開啟 → 關閉)
- 導覽與版面連貫性(共享元素、淡入淡出/Crossfade)
考量事項
- 無障礙功能:務必支援「減少動態效果」(Reduced motion)
- 裝置適配:針對低階/舊型裝置進行調整
- 效能權衡:響應速度高於視覺流暢度
應避免使用動效的情況
- 完全僅用於裝飾
- 會降低易用性或清晰度
- 對效能產生負面影響
運作原理
核心原則
動效必須具備以下至少一項作用:
- 引導注意力
- 傳達狀態
- 維持空間連貫性
若一項都不符合 → 請直接移除該動效。
安裝方式
npm install motion
版本選擇
motion/react- 當前 Motion for React 專案的預設路徑(套件名稱:motion)framer-motion- 仍依賴 Framer Motion 的專案所使用的舊版(Legacy)匯入路徑
切勿混用。 混用會導致內部排程器(Scheduler)衝突並破壞 AnimatePresence 的 Context 上下文——來自不同套件的元件將無法協調彼此的退場動畫(Exit animations)。
欲確認您的專案使用哪個版本:
cat package.json | grep -E '"motion"|"framer-motion"'
請始終保持從單一來源一致匯入:
// 正確(新版)
import { motion, AnimatePresence } from "motion/react"
// 正確(舊版)
import { motion, AnimatePresence } from "framer-motion"
// 切勿在同一個專案中混用兩者
動效 Token (Motion Tokens)
// motionTokens.ts
export const motionTokens = {
duration: {
fast: 0.18,
normal: 0.35,
slow: 0.6
},
// 作為 `transition` 物件內的 `ease` 值使用:
// transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
easing: {
smooth: [0.22, 1, 0.36, 1] as [number, number, number, number],
sharp: [0.4, 0, 0.2, 1] as [number, number, number, number]
},
distance: {
sm: 8,
md: 16,
lg: 24
}
}
使用範例:
import { motionTokens } from "@/lib/motionTokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.md }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth
}}
/>
效能規則
安全(建議使用)
- transform
- opacity
避免使用
- width / height
- top / left
法則:響應速度 > 視覺流暢度
裝置適配
此啟發式判斷結合了 CPU 核心數與可用記憶體,能提供更可靠的效能指標。deviceMemory 僅支援 Chrome/Android;備用邏輯(Fallback)則涵蓋 Safari 與 Firefox。
const isLowEnd =
typeof navigator !== "undefined" && (
// 低記憶體(僅限 Chrome/Android;其他瀏覽器為 undefined → 視為效能足夠)
(navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) ||
// 核心數少且不支援記憶體 API(涵蓋低階硬體上的 Safari/Firefox)
(navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4)
)
const duration = isLowEnd ? 0.2 : 0.4
無障礙功能 (Accessibility)
JS (useReducedMotion)
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
/>
)
}
CSS
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition {
transition: opacity 0.2s;
}
.motion-reduce-transform {
transform: none !important;
}
}
Tailwind
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
架構與設計模式
核心模式
| 場景 | 模式 |
|---|---|
| 滑鼠懸停回饋 (Hover) | whileHover |
| 點擊 / 按壓回饋 (Tap/Press) | whileTap |
| 捲動顯現 (Scroll reveal) | whileInView |
| 捲動連動數值 | useScroll + useTransform |
| 條件式掛載 / 卸載 | AnimatePresence |
| 微小版面位移(單一元素,位移 < ~300px) | layout 屬性 |
| 大型版面位移或全頁面重繪 (Reflow) | 避免使用 layout;改用 CSS 轉場或頁面級路由 |
| 複雜的命令式動畫序列 | useAnimate |
為什麼要在大型容器上避免使用
layout? Framer 的版面動畫雖然透過transform計算並補正位置,但若套用在佔滿整個視埠(Viewport)或會觸發深層重繪(Reflow)的元素上,其位置量測成本會導致明顯的卡頓(Jank)與版面位移(CLS)。建議改用 CSS Grid/Flexbox 轉場,或僅在特定子元素上搭配layoutId進行協調。
版面配置與轉場
- 共享元素轉場 →
layoutId(每個已掛載的實例必須具備唯一 key) - 進場 / 退場轉場 →
AnimatePresence(請參閱下方的mode使用指引)
AnimatePresence 的 mode 設定
務必明確指定 mode——預設值("sync")會同時執行進場與退場動畫,在大多數 UI 模式中會造成視覺重疊現象。
mode |
使用時機 |
|---|---|
"wait" |
退場動畫完全結束後,才開始進場動畫。適用於對話盒 (Modal)、Toast 提示、頁面轉場。 |
"sync"(預設) |
進場與退場動畫同時發生。僅在刻意需要重疊效果時使用(例如交錯淡入淡出的輪播 Carousel)。 |
"popLayout" |
退場元素會立即脫離文件流(Flow);剩餘項目則以動畫補位。適用於清單、分頁標籤 (Tabs)、可消除的卡片。 |
// Modal — 務必使用 "wait"
<AnimatePresence mode="wait">
{open && <Modal key="modal" />}
</AnimatePresence>
// 可消除的清單項目 — 使用 "popLayout"
<AnimatePresence mode="popLayout">
{items.map(item => <Card key={item.id} />)}
</AnimatePresence>
進階模式(概念範疇)
- 視差效果(捲動連動的 Transform 變形)
- 捲動敘事(黏性定位區塊 Sticky sections)
- 3D 傾斜(基於指標位置的 Transform 變形)
- 交叉淡入淡出(共享
layoutId) - 漸進式顯露(
clip-path) - 骨架屏載入動畫(不間斷迴圈不透明度 Opacity)
- 微互動(懸停與點擊回饋)
- 彈簧物理系統(基於物理的動效)
Modal(對話盒)必備要素
- 焦點捕捉 (Focus trap)
- 按 Esc 鍵關閉
- 捲動鎖定 (Scroll lock)
- 正確的 ARIA Role 標籤
- 使用
AnimatePresence mode="wait",確保退場動畫播放完畢後才載入下一個 Modal
完整範例
import React, { useEffect, useRef, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
function useFocusTrap(ref: React.RefObject<HTMLDivElement | null>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return
const el = ref.current
const focusable = el.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
function handleKey(e: KeyboardEvent) {
if (e.key !== "Tab") return
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last?.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first?.focus()
}
}
el.addEventListener("keydown", handleKey)
first?.focus()
return () => el.removeEventListener("keydown", handleKey)
}, [active, ref])
}
function useScrollLock(active: boolean) {
useEffect(() => {
if (!active) return
const prev = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => { document.body.style.overflow = prev }
}, [active])
}
function Modal({ open, closeModal }: { open: boolean; closeModal: () => void }) {
const ref = useRef<HTMLDivElement>(null)
useFocusTrap(ref, open)
useScrollLock(open)
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") closeModal()
}
if (open) window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, closeModal])
return (
// mode="wait" 可確保退場動畫結束後才呈現任何新進入的 Modal
<AnimatePresence mode="wait">
{open && (
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 flex items-center justify-center bg-black/40"
>
<motion.div
ref={ref}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="bg-white p-6 rounded"
>
<h2 id="modal-title">Dialog Title</h2>
<button onClick={closeModal}>Close</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
export function Example() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<Modal open={open} closeModal={() => setOpen(false)} />
</>
)
}
SSR 安全規範
- 確保伺服器端與用戶端渲染的初始狀態完全一致
- 避免隱式的動畫起點(務必明確設定
initial) - 在 Next.js App Router 中,請於動效元件頂部標註
"use client"
除錯檢查清單
請檢查以下事項:
- 匯入路徑錯誤(混用了
motion/react與framer-motion) - 在 Next.js App Router 中漏掉
"use client"指令 AnimatePresence的子元件缺少key屬性- Hydration 不一致(伺服器端與用戶端的初始狀態不同)
- 在大型容器上誤用
layout屬性,導致重繪卡頓 - 依賴狀態驅動的動畫未觸發(檢查 useEffect / Hook 的依賴陣列)
QA 測試驗收點
- 無累積版面配置位移 (CLS)
- 鍵盤操作正常
- 焦點成功鎖定在 Modal 內
- ARIA Role 設定正確 (
role="dialog",aria-modal="true") - 符合「減少動態效果」偏好 (
useReducedMotion+ CSS Media Query) - Next.js 中無任何 Hydration 警告
- 元件卸載時動畫乾淨終止(無記憶體洩漏風險)
- 所有使用到
AnimatePresence之處皆已明確設定mode
反模式 (Anti-Patterns)
- 對版面排版屬性製作動畫(如
width,height,top,left) - 無明確目的的無限循環動畫(請思考:該動畫傳達了什麼狀態?)
- 過度的清單交錯動畫(
staggerChildren應保持 ≤ 0.1s,超過會讓人感覺遲鈍) - 忽視「減少動態效果」的使用者設定
- 在大型或全螢幕視埠容器上使用
layout屬性 - 在
AnimatePresence上漏設定mode(預設"sync"會引發視覺重疊) - 純粹為了裝飾而使用動效
核心理念
動效即互動設計。
最終法則
若動效無法提升 UX → 請直接移除。
範例
按鈕互動
import { motion } from "motion/react"
export function Button() {
return (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
>
Click me
</motion.button>
)
}
減少動態效果範例
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.1 : 0.35, ease: [0.22, 1, 0.36, 1] }}
/>
)
}
交錯動畫清單
import { motion } from "motion/react"
const container = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08 } // 保持 ≤ 0.1s 以避免遲滯感
}
}
const item = {
hidden: { opa






