motion-ui
affaan-m/ECC
為 React/Next.js 提供一套適用於生產環境的 UI 動態系統,並附有效能、無障礙及易用性準則。
...展開全部Motion System v4.2
適用於 React / Next.js 的生產就緒型 UI 動態系統。
著重於效能、無障礙性與易用性——而非裝飾。
何時使用
當動畫需:
- 引導使用者注意力(例如:新手引導、關鍵操作)
- 傳達狀態(載入中、成功、錯誤、過渡)
- 維持空間連續性(版面配置變更、導航)
適用情境
- 互動元件(按鈕、模態視窗、選單)
- 狀態轉換(載入中 → 已載入、開啟 → 關閉)
- 導覽與版面配置的連續性(共用元素、淡入淡出)
注意事項
- 無障礙設計:應始終支援減少動態效果
- 裝置適應性:針對低階裝置進行調整
- 效能權衡:優先考量反應速度,而非視覺流暢度
以下情況應避免使用動態效果
- 純粹用於裝飾時
- 會降低可用性或清晰度時
- 會對效能造成負面影響時
運作原理
核心原則
動態效果必須:
- 引導注意力
- 傳達狀態
- 維持空間連續性
若未能達成任何一項 → 則予以移除。
裝置藝術
npm install motion
版本
motion/react- 當前 Motion for React 專案的預設設定(套件:motion)framer-motion- 仍依賴 Framer Motion 的專案所用的舊版導入路徑
請勿混合使用。混合使用會導致內部排程器衝突,並破壞 AnimatePresence ——來自其中一個套件的元件將無法與來自另一個套件的元件協調退出動畫。
要檢查您的專案使用哪個版本:
cat package.json | grep -E '"motion"|"framer-motion"'
請始終一致地從單一來源導入:
// Correct (modern)
import { motion, AnimatePresence } from "motion/react"
// Correct (legacy)
import { motion, AnimatePresence } from "framer-motion"
// Never mix both in the same project
Motion Tokens
// motionTokens.ts
export const motionTokens = {
duration: {
fast: 0.18,
normal: 0.35,
slow: 0.6
},
// Use these as the `ease` value inside a `transition` object:
// 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"
效能規則
安全
- 變換
- 不透明度
應避免
- 寬度 / 高度
- 頂部 / 左側
原則:響應式設計 > 流暢度
裝置適應性
此啟發式方法結合 CPU 核心數與可用記憶體,以提供更可靠的訊號。 deviceMemory 此功能適用於 Chrome/Android;備用方案則涵蓋 Safari 與 Firefox。
const isLowEnd =
typeof navigator !== "undefined" && (
// Low memory (Chrome/Android only; undefined elsewhere → treat as capable)
(navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) ||
// Few cores AND no memory API (covers Safari/Firefox on weak hardware)
(navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4)
)
const duration = isLowEnd ? 0.2 : 0.4
無障礙功能
JS (useReducedMotion)
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
)
}
CSS
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition {
transition: opacity 0.2s;
}
.motion-reduce-transform {
transform: none !important;
}
}
Tailwind
架構與模式
核心模式
| 情境 | 模式 |
|---|---|
| 懸停回饋 | whileHover |
| 點擊/按壓回饋 | whileTap |
| 隨捲動顯示 | whileInView |
| 與捲動相關的數值 | useScroll + useTransform |
| 條件式掛載/卸載 | AnimatePresence |
| 微幅佈局變動(單一元素,變動幅度 < ~300px) | layout 屬性 |
| 大幅的版面位移或全頁重新排版 | 應避免 layout;請改用 CSS 轉場效果或頁面級路由 |
| 複雜的命令式序列 | useAnimate |
為何應避免在大型容器上使用 `
layout`?Framer 的佈局動畫會使用transform來調整位置,但在橫跨整個檢視窗或觸發深度重排的元素上,測量開銷會導致可見的卡頓與 CLS。建議優先採用 CSS Grid/Flexbox 轉場效果,或僅針對layoutId僅針對特定子元素進行協調。
佈局與轉場
- 共用元素的過渡效果 →
layoutId(每個已掛載的實例必須具有唯一性) - 進入/退出過渡 →
AnimatePresence(參見mode下方指引)
AnimatePresence mode
請務必 mode 明確指定 — 預設值("sync")會同時執行進入與退出效果,這在大多數 UI 模式中會導致視覺上的重疊。
mode |
何時使用 |
|---|---|
"wait" |
「Exit」會在「enter」開始前完成。適用於模態視窗、提示框及頁面轉場。 |
"sync" (預設) |
進入與退出效果會重疊。僅在重疊屬於刻意設計時使用(例如:淡入淡出輪播)。 |
"popLayout" |
退出元素會立即從流程中彈出;剩餘項目會透過動畫填補空缺。適用於清單、標籤頁、可關閉的卡片。 |
// Modal — always use "wait"
{open && }
// Dismissible list item — use "popLayout"
{items.map(item => )}
進階模式(概念)
- 視差效果(與捲動連動的變形)
- 捲動敘事(固定區塊)
- 3D 傾斜(基於游標的變換)
- 交叉淡入淡出(共享
layoutId) - 漸進式顯示(剪裁路徑)
- 骨架載入(循環不透明度)
- 微互動(懸停/點擊回饋)
- 彈簧系統(基於物理的運動)
模態視窗要點
- 焦點陷阱
- 逃脫關閉
- 捲動鎖定
- ARIA 角色
- 使用
AnimatePresence mode="wait",以便在下一模態視窗出現前完成退出動畫
完整範例
import React, { useEffect, useRef, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
function useFocusTrap(ref: React.RefObject, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return
const el = ref.current
const focusable = el.querySelectorAll(
'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(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" ensures exit animation finishes before any new modal enters
{open && (
Dialog Title
)}
)
}
export function Example() {
const [open, setOpen] = useState(false)
return (
<>
SSR 安全性
- 確保伺服器端與客戶端渲染的初始狀態一致
- 避免隱含的動畫起點(請始終
initial顯式設定) - 將動態元件包裹在
"use client"Next.js 應用程式路由器中
除錯
檢查:
- 導入錯誤(混用
motion/react與framer-motion) - 缺少
"use client"指令) - 缺少
keyprops 屬性於AnimatePresencechildren - 初始化狀態不匹配(伺服器端渲染與客戶端渲染的初始狀態不同)
layout在大型容器上誤用 prop 導致重排卡頓- 狀態驅動的動畫未觸發(請檢查依賴性陣列)
品質保證
- 無 CLS
- 鍵盤功能正常
- 焦點卡在模態視窗中
- ARIA 角色正確(
role="dialog",aria-modal="true") - 已遵循「減少動態效果」設定(
useReducedMotion+ CSS 媒體查詢) - Next.js 中無水化警告
- 卸載時動畫會乾淨俐落地停止(無記憶體洩漏)
AnimatePresence mode在所有使用處皆明確設定
反模式
- 對佈局屬性進行動畫處理(
width,height,top,left) - 無目的的無限動畫(請始終自問:這傳達了什麼狀態?)
- 清單的交錯效果過於頻繁(應保持
staggerChildren≤ 0.1 秒;超過此時間會讓人感覺遲緩) - 忽略「減少動態效果」的偏好設定
- 在
layout於大型或全視口容器上 - 省略
mode於AnimatePresence(預設"sync"會導致視覺重疊) - 純粹將動態效果用於裝飾
哲學
動態即為互動設計。
最終準則
若動態效果無法提升使用者體驗 → 則應移除。
範例
按鈕互動
import { motion } from "motion/react"
export function Button() {
return (
Click me
)
}
減少動態效果的範例
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
)
}
錯落式清單
import { motion } from "motion/react"
const container = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08 } // keep ≤ 0.1s to avoid sluggishness
}
}
const item = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] } }
}
export function List() {
return (
{[1, 2, 3].map(i => (
Item {i}
))}
)
}
帶有 AnimatePresence 的模態視窗
import { motion, AnimatePresence } from "motion/react"
export function Modal({ open }: { open: boolean }) {
return (
{open && (
)}
)
}
捲動視差效果
import { useScroll, useTransform, motion } from "motion/react"
export function Parallax() {
const { scrollYProgress } = useScroll()
const y = useTransform(scrollYProgress, [0, 1], [0, -80])
return
}
骨架式載入
import { motion } from "motion/react"
export function Skeleton() {
return (
)
}
共享佈局(交叉淡入淡出)
import { motion } from "motion/react"
// layoutId must be unique per mounted instance.
// If multiple instances can exist simultaneously, append a unique id:
// layoutId={`shared-${item.id}`}
export function Shared() {
return
}
---
name: motion-ui
description: Provides a production-ready UI motion system for React/Next.js with performance, accessibility, and usability guidelines.
---
# Motion System v4.2
Production-ready UI motion system for React / Next.js.
Focused on **performance, accessibility, and usability** — not decoration.
## When to Use
Use this motion system when motion:
* Guides attention (e.g., onboarding, key actions)
* Communicates state (loading, success, error, transitions)
* Preserves spatial continuity (layout changes, navigation)
### Appropriate Scenarios
* Interactive components (buttons, modals, menus)
* State transitions (loading → loaded, open → closed)
* Navigation and layout continuity (shared elements, crossfade)
### Considerations
* **Accessibility**: Always support reduced motion
* **Device adaptation**: Adjust for low-end devices
* **Performance trade-offs**: Prefer responsiveness over visual smoothness
### Avoid Using Motion When
* It is purely decorative
* It reduces usability or clarity
* It impacts performance negatively
---
## How It Works
### Core Principle
Motion must:
* Guide attention
* Communicate state
* Preserve spatial continuity
If it does none → remove it.
---
### Installation
```bash
npm install motion
```
---
### Version
* `motion/react` - default for current Motion for React projects (package: `motion`)
* `framer-motion` - legacy import path for projects that still depend on Framer Motion
**Do not mix.** Mixing causes conflicting internal schedulers and broken `AnimatePresence` contexts — components from one package will not coordinate exit animations with components from the other.
To check which version your project uses:
```bash
cat package.json | grep -E '"motion"|"framer-motion"'
```
Always import from one source consistently:
```ts
// Correct (modern)
import { motion, AnimatePresence } from "motion/react"
// Correct (legacy)
import { motion, AnimatePresence } from "framer-motion"
// Never mix both in the same project
```
---
### Motion Tokens
```ts
// motionTokens.ts
export const motionTokens = {
duration: {
fast: 0.18,
normal: 0.35,
slow: 0.6
},
// Use these as the `ease` value inside a `transition` object:
// 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
}
}
```
Usage example:
```tsx
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
}}
/>
```
---
### Performance Rules
**Safe**
* transform
* opacity
**Avoid**
* width / height
* top / left
Rule: responsiveness > smoothness
---
### Device Adaptation
The heuristic combines CPU core count **and** available memory for a more reliable signal. `deviceMemory` is available on Chrome/Android; the fallback covers Safari and Firefox.
```ts
const isLowEnd =
typeof navigator !== "undefined" && (
// Low memory (Chrome/Android only; undefined elsewhere → treat as capable)
(navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) ||
// Few cores AND no memory API (covers Safari/Firefox on weak hardware)
(navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4)
)
const duration = isLowEnd ? 0.2 : 0.4
```
---
### Accessibility
#### JS (useReducedMotion)
```tsx
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
```css
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition {
transition: opacity 0.2s;
}
.motion-reduce-transform {
transform: none !important;
}
}
```
#### Tailwind
```html
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
```
---
### Architecture & Patterns
#### Core Patterns
| Scenario | Pattern |
|---|---|
| Hover feedback | `whileHover` |
| Tap / press feedback | `whileTap` |
| Reveal on scroll | `whileInView` |
| Scroll-linked value | `useScroll` + `useTransform` |
| Conditional mount/unmount | `AnimatePresence` |
| Small layout shifts (single element, < ~300px change) | `layout` prop |
| Large layout shifts or full-page reflows | Avoid `layout`; use CSS transitions or page-level routing instead |
| Complex, imperative sequences | `useAnimate` |
> **Why avoid `layout` on large containers?** Framer's layout animation uses `transform` to reconcile positions, but on elements that span the full viewport or trigger deep reflow, the measurement cost causes visible jank and CLS. Prefer CSS Grid/Flexbox transitions or coordinate with `layoutId` on specific child elements only.
#### Layout & Transitions
* Shared element transitions → `layoutId` (must be unique per mounted instance)
* Enter / exit transitions → `AnimatePresence` (see `mode` guidance below)
#### AnimatePresence `mode`
Always specify `mode` explicitly — the default (`"sync"`) runs enter and exit simultaneously, which causes visual overlap in most UI patterns.
| `mode` | When to use |
|---|---|
| `"wait"` | Exit completes before enter starts. Use for **modals, toasts, page transitions**. |
| `"sync"` (default) | Enter and exit overlap. Use only when overlap is intentional (e.g., crossfade carousels). |
| `"popLayout"` | Exiting element is popped out of flow immediately; remaining items animate to fill. Use for **lists, tabs, dismissible cards**. |
```tsx
// Modal — always use "wait"
<AnimatePresence mode="wait">
{open && <Modal key="modal" />}
</AnimatePresence>
// Dismissible list item — use "popLayout"
<AnimatePresence mode="popLayout">
{items.map(item => <Card key={item.id} />)}
</AnimatePresence>
```
---
### Advanced Patterns (Concepts)
* Parallax (scroll-linked transforms)
* Scroll storytelling (sticky sections)
* 3D tilt (pointer-based transforms)
* Crossfade (shared `layoutId`)
* Progressive reveal (clip-path)
* Skeleton loading (looped opacity)
* Micro-interactions (hover/tap feedback)
* Spring system (physics-based motion)
---
### Modal Essentials
* Focus trap
* Escape close
* Scroll lock
* ARIA roles
* Use `AnimatePresence mode="wait"` so exit animation completes before the next modal enters
#### Full Example
```tsx
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" ensures exit animation finishes before any new modal enters
<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 Safety
* Match initial states between server and client renders
* Avoid implicit animation origins (always set `initial` explicitly)
* Wrap motion components in `"use client"` in Next.js App Router
---
### Debugging
Check:
* Wrong import (mixing `motion/react` and `framer-motion`)
* Missing `"use client"` directive in Next.js App Router
* Missing `key` prop on `AnimatePresence` children
* Hydration mismatch (initial state differs between SSR and client)
* `layout` prop misuse on large containers causing reflow jank
* State-driven animation not triggering (check dependency arrays)
---
### QA
* No CLS
* Keyboard works
* Focus trapped in modals
* ARIA roles correct (`role="dialog"`, `aria-modal="true"`)
* Reduced motion respected (`useReducedMotion` + CSS media query)
* No hydration warnings in Next.js
* Animations stop cleanly on unmount (no memory leaks)
* `AnimatePresence mode` set explicitly on all usage sites
---
### Anti-Patterns
* Animating layout properties (`width`, `height`, `top`, `left`)
* Infinite animations without purpose (always ask: what state does this communicate?)
* Over-staggering lists (keep `staggerChildren` ≤ 0.1s; beyond that it feels slow)
* Ignoring reduced motion preferences
* Using `layout` on large or full-viewport containers
* Omitting `mode` on `AnimatePresence` (default `"sync"` causes visual overlap)
* Using motion purely for decoration
---
### Philosophy
Motion is interaction design.
---
### Final Rule
> If motion does not improve UX → remove it.
---
## Examples
### Button Interaction
```tsx
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>
)
}
```
---
### Reduced Motion Example
```tsx
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] }}
/>
)
}
```
---
### Stagger List
```tsx
import { motion } from "motion/react"
const container = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08 } // keep ≤ 0.1s to avoid sluggishness
}
}
const item = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] } }
}
export function List() {
return (
<motion.ul variants={container} initial="hidden" animate="visible">
{[1, 2, 3].map(i => (
<motion.li key={i} variants={item}>Item {i}</motion.li>
))}
</motion.ul>
)
}
```
---
### Modal with AnimatePresence
```tsx
import { motion, AnimatePresence } from "motion/react"
export function Modal({ open }: { open: boolean }) {
return (
<AnimatePresence mode="wait">
{open && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
)}
</AnimatePresence>
)
}
```
---
### Scroll Parallax
```tsx
import { useScroll, useTransform, motion } from "motion/react"
export function Parallax() {
const { scrollYProgress } = useScroll()
const y = useTransform(scrollYProgress, [0, 1], [0, -80])
return <motion.div style={{ y }} />
}
```
---
### Skeleton Loading
```tsx
import { motion } from "motion/react"
export function Skeleton() {
return (
<motion.div
className="bg-gray-200 h-6 w-full rounded"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1.5, // comfortable pulse — was missing, caused fast flash
repeat: Infinity,
ease: "easeInOut"
}}
/>
)
}
```
---
### Shared Layout (Crossfade)
```tsx
import { motion } from "motion/react"
// layoutId must be unique per mounted instance.
// If multiple instances can exist simultaneously, append a unique id:
// layoutId={`shared-${item.id}`}
export function Shared() {
return <motion.div layoutId="shared" />
}
```
所有檔案
1 個檔案安裝 motion-ui
請下載並將技能檔案解壓縮至您的 .claude/skills/ 目錄中。
下載 ZIP複製儲存庫並將技能檔案複製到您的專案中。
git clone https://github.com/affaan-m/ECC/tree/main/skills/motion-ui # Copy SKILL.md to your .claude/skills/ directory
複製





首頁
