React 動畫
安裝本插件時 docs/react/ 目錄不存在 — 若無法開啟此檔案中的 docs/... 路徑,請在 https://raw.githubusercontent.com/joo6077/claude-plugins/main/ 後接相同路徑讀取;若仍無法讀取,請如實記錄無法讀取,切勿編造內容。
注意事項
-
零函式庫原則 — 絕無例外:Motion (framer-motion) / dnd-kit / react-spring / react-transition-group / @formkit/auto-animate / react-dnd / gsap / lottie-react / react-beautiful-dnd / animate.css 等禁止安裝與匯入。若程式碼庫中存在其 import 語句,
/react-audit將會觸發建置失敗。即使使用者要求,也應提出替代實作方案並拒絕使用函式庫。 -
從最低層級開始嘗試:收到「動畫」要求時,常犯錯誤是直接使用 Tier 3。請依照判定規則,先檢查是否可透過 Tier 1 解決,只有當無法解決時才升級至更高層級。
-
確認
tailwindcss-animate插件:animate-in、fade-in-*、slide-in-from-*等擴充工具類需有tailwindcss-animate才能運作。安裝了 shadcn 的專案預設包含,否則請執行pnpm add -D tailwindcss-animate並在tailwind.config.ts中註冊插件。 -
view-transition-name唯一性:若兩個元素同時擁有相同名稱,將產生「重複名稱」錯誤。務必使用動態 ID(如view-transition-name: task-${item.id})以確保唯一性。 -
必須處理 Pointer cancel:未處理
pointercancel事件將導致切換分頁、切換至其他應用程式或元素卸載時拖曳狀態外洩。必須在onPointerCancel處理器中將 FSM 重置為idle。 -
遺漏
touch-action: none會使行動裝置無法拖曳:基於 pointer event 的拖曳元素必須加上 Tailwind 的touch-none類別,才能阻止瀏覽器預設的滾動/縮放行為。 -
Zustand drag store 為全域單一實例:請勿為每個功能建立獨立的 drag store。所有功能應共用
src/presentation/shared/stores/drag-store.ts這一個 store。 -
未套用
prefers-reduced-motion:所有層級都必須使用@media (prefers-reduced-motion: reduce)或 Tailwind 的motion-reduce:*變體來停用動畫。若有遺漏,/react-audit將發出警告。 -
動畫僅限於 presentation 層:請勿在
domain/、data/層匯入 pointer event / view transition / Zustand drag store。與動畫相關的程式碼應僅位於src/presentation/中。 -
Firefox 不支援 types 參數:
document.startViewTransition({ update, types: [...] })的types選項截至 2026 年僅在 Chromium 系列中穩定支援。withViewTransition包裝器中需以 try/catch 處理降級方案。 -
滾驅動動畫瀏覽器支援 (2026-08-13 更新):
animation-timeline: scroll()/scroll-timeline根據 Can I Use 資料,全球支援率 85.43% — Chrome/Edge 115+、Safari/iOS 26+、Firefox 156+。Firefox 需啟用旗標的說法已過時。但 MDN 顯示view()仍為 not Baseline,因此以@supports (animation-timeline: scroll())/@supports (animation-timeline: view())包裹,並在未支援時回退至靜態樣式的原則仍應維持。 -
善用
view-transition-name: match-element(Chrome 137+):與其手動為數十個元素賦予唯一名稱,不如使用view-transition-name: match-element這個 CSS 值,讓瀏覽器自動匹配元素。但此功能在 2026-Q2 仍為 Chromium 專屬,請保留降級方案。 -
動畫無法以靜態程式碼證明 — 完成宣告前須備妥證據 (E2):此技能產出的成果必須實際播放才能驗證其存在。擁有
animate-*類別或呼叫了startViewTransition僅屬 R3 (靜態) 證據,並非動畫實際播放的證明 — 未定義的 keyframe、prefers-reduced-motion持續命中、父元素的overflow: hidden裁切、不支援的瀏覽器進入降級方案,這些情況都會靜悄悄地導致「什麼事都沒發生」。完成前務必填寫react-kit/references/render-evidence-protocol.md§4 的檢查清單。使用快照比對驗證時,必須指定前/後兩個時間點 — 單張靜止畫面對任何動畫都會產生相同結果,因此不具判定效力 (§3 d)。若無法取得證據,請標示[未驗證]並填寫四個欄位 (阻礙因素 · 嘗試的避開方式 · 無法掌控的原因 · 重新驗證指令 — 協定 §2),以部分完成狀態回報。切勿為取得證據而引入動畫函式庫。 函式庫政策 (注意事項 #1) 優先於此協定,且不因任何驗證便利性而放寬。
-
基準擷取應在編輯前進行 — 在首次編輯前執行
react-kit/references/render-evidence-protocol.md§1 Step 0 與 §2 比較重複流程中的步驟 1,並將結果 (重述 · 慣例表 · 當前樣貌的擷取路徑與應改變的標記) 記錄於回應中。編輯完成後即無法重新拍攝編輯前的樣貌。 -
Tier 2 依更新類型區分 — React 19.3 的
<ViewTransition>為穩定 API (2026-09-26 新增):從react匯入的<ViewTransition>已在 19.3 中成為穩定 API,不再是實驗性 API。當被包裹的子元件透過標記為 Transition 的更新 (startTransition內的更新 · Suspense reveal ·useDeferredValue) 改變樣式、掛載或卸載時觸發,且僅在 DOM 中運作 (來源: https://react.dev/blog/2026/09/09/react-19-3 「We shared it as an experimental API last year, and in 19.3 it's stable and ready to use.」)。因此,對於標記為 Transition 的 React 狀態更新,請使用<ViewTransition>;對於其他 DOM 更新 (非 Transition 的同步更新 · React 外部的 DOM 操作) 則繼續使用 §3.1 的withViewTransition包裝器。請勿移除包裝器。由於這是 react 套件內的元件,因此不違反零函式庫原則 (#1)。若專案react版本低於 19.3,則僅使用包裝器。
流程
1. 自動層級判定
若未提供 --tier 旗標,則分析使用者請求並選擇最低層級。
| 關鍵字 / 情境 | 判定層級 |
|---|---|
| 「淡入」、「上滑」、「縮放」、「脈衝」、「彈跳」、「懸浮效果」、「透明度」、「閃爍效果」、「進場動畫」 | T1 |
| 「骨架載入 → 完成轉場」、「按鈕懸浮」、「手風琴」、「模態框開啟/關閉」、「狀態變化」 | T1 |
| 「滾動動畫」、「滾動驅動」、「視差效果」、「滾動進度條」、「基於滾動」 | T1 |
| 「頁面轉場」、「共享元素」、「網格轉看板」、「視圖轉場」、「路由轉場」、「DOM 結構變更」 | T2 |
| 「滾動觸發」、「滾動交叉觸發」、「特定位置啟動動畫」 | T2 |
| 「拖放」、「可排序清單」、「排序」、「看板」、「拖曳」、「投放」、「手勢」、「箭頭連線」 | T3 |
若 T2/T3 邊界模糊,請諮詢 animation-architect-react 代理。
三層級摘要:
| 層級 | 工具 | 適用情境 | 難度 |
|---|---|---|---|
| T1 | Tailwind animate-* + CSS @keyframes + scroll-driven | 狀態變化、懸浮、簡單循環、滾動連動 | 低 |
| T2 | View Transitions API (withViewTransition 包裝器) · React 19.3 <ViewTransition> (僅限標記為 Transition 的更新 — 注意事項 15) | 視圖/路由轉場、共享元素、FLIP | 中 |
| T3 | Pointer Events + FSM + requestAnimationFrame | 拖放、手勢、SVG 連線 | 高 |
2. Tier 1 — Tailwind + CSS 實作
2.1 Tailwind 內建工具類
| 類別 | 工具 | 範例 |
|---|---|---|
| Transition | transition-transform、transition-colors、transition-opacity | hover:scale-105 transition-transform duration-200 |
| Duration | duration-75~duration-1000 | duration-300 |
| Easing | ease-linear、ease-in、ease-out、ease-in-out | ease-in-out |
| Animation (內建) | animate-spin、animate-ping、animate-pulse、animate-bounce | 載入、通知 |
| Delay | delay-75~delay-1000 | 交錯效果 |
2.2 tailwindcss-animate 擴充工具
tailwindcss-animate 插件提供的進入/離開動畫:
animate-in/animate-outfade-in-*、fade-out-*slide-in-from-top/bottom/left/right-*、slide-out-to-*zoom-in-*、zoom-out-*
與 data-[state=...] 屬性選擇器結合,實作基於狀態的動畫:
// 抽屜開啟/關閉範例
<div
data-state={open ? 'open' : 'closed'}
className="
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left
duration-300
motion-reduce:transition-none motion-reduce:animate-none
"
>
抽屜內容
</div>
2.3 CSS @keyframes 直接宣告
對於 Tailwind 工具類無法覆蓋的自訂動畫,請在 globals.css 的 @theme 區塊中宣告:
/* src/presentation/styles/globals.css */
@import "tailwindcss";
@theme {
--animate-wiggle: wiggle 1s ease-in-out infinite;
--animate-shimmer: shimmer 2s linear infinite;
@keyframes wiggle {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
使用方式:<div className="animate-wiggle">...</div>
2.4 Tier 1 情境範例
淡入進場:
<div className="animate-in fade-in-0 duration-500 motion-reduce:animate-none">
頁面載入時淡入進場
</div>
懸浮縮放按鈕:
<button className="transition-transform duration-200 hover:scale-105 active:scale-95 motion-reduce:transition-none">
按鈕
</button>
交錯清單進場:
{items.map((item, i) => (
<div
key={item.id}
className="animate-in slide-in-from-bottom-4 fade-in-0 motion-reduce:animate-none"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'backwards' }}
>
{item.name}
</div>
))}
2.5 滾驅動動畫 (CSS 原生)
使用 CSS animation-timeline 在無 JavaScript 的情況下實作與滾動位置連動的動畫。截至 2026-08-13,scroll() / scroll-timeline 已可在 Chrome/Edge 115+、Safari/iOS 26+、Firefox 156+ 中使用 (Can I Use 全球支援率 85.43%),而 view() 在 MDN 上仍為 not Baseline。兩種情況皆以 @supports 包裹,在未支援的瀏覽器上優雅降級。
滾動進度條:
/* globals.css */
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
<div
className="fixed top-0 left-0 h-1 w-full origin-left bg-primary motion-reduce:hidden"
style={{
animation: 'progress-grow linear',
animationTimeline: 'scroll(root block)',
}}
/>
元素進場時淡入 (view timeline):
@keyframes fade-slide-in {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
@supports (animation-timeline: view()) {
.scroll-reveal {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
<section className="scroll-reveal motion-reduce:opacity-100">
滾動時出現的內容
</section>
注意:滾動觸發動畫 (Chrome 145, 2026) 是一個不同的概念,指在特定滾動偏移交叉時觸發基於時間的動畫。請勿與滾驅動動畫混淆。滾動觸發動畫歸類為 Tier 2 候選。
3. Tier 2 — View Transitions API 實作
同文件 (same-document) View Transitions 在 MDN 上為 Baseline 2025,Can I Use 全球支援率 90.2% — Chrome/Edge 111+、Safari 18+、Firefox 144+ (2026-08-13 更新)。適用於 SPA 內 DOM 變更時自動 FLIP 動畫。
而基於 @view-transition 的 跨文件 / MPA 轉場 以及部分細節功能仍屬 limited availability。用於路由轉場時,應以同文件路徑為預設,並保留功能檢測 + 降級方案 (§6 標準覆蓋率空白 8 項)。
3.1 建立 withViewTransition 包裝器
若檔案不存在則建立:
// src/presentation/shared/lib/view-transition.ts
export function withViewTransition(
updateDOM: () => void,
options?: { types?: string[] },
): void {
// 尊重 prefers-reduced-motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateDOM()
return
}
// 功能檢測 (舊版瀏覽器降級)
if (typeof document.startViewTransition !== 'function') {
updateDOM()
return
}
// 依 types 參數支援與否分支 (Firefox 初始實作不支援)
try {
if (options?.types && options.types.length > 0) {
document.startViewTransition({ update: updateDOM, types: options.types })
} else {
document.startViewTransition(updateDOM)
}
} catch {
updateDOM()
}
}
3.2 view-transition-name CSS 共享元素
即使 DOM 結構改變,也能將元素識別為「相同元素」並平滑移動:
// 使用動態 id 確保唯一性 (禁止重複名稱)
<div
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
3.3 網格 ↔ 看板視圖轉場 (代表性情境)
// src/presentation/features/tasks/components/task-switcher.tsx
import { withViewTransition } from '@/presentation/shared/lib/view-transition'
import { useTaskStore } from '../store'
export function TaskViewSwitcher() {
const viewMode = useTaskStore((s) => s.viewMode)
const setViewMode = useTaskStore((s) => s.setViewMode)
return (
<button
onClick={() => {
withViewTransition(() => {
setViewMode(viewMode === 'grid' ? 'board' : 'grid')
})
}}
>
{viewMode === 'grid' ? '切換至看板視圖' : '切換至網格視圖'}
</button>
)
}
export function TaskList({ tasks }: { tasks: Task[] }) {
const viewMode = useTaskStore((s) => s.viewMode)
return (
<div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'flex gap-4 overflow-x-auto'}>
{tasks.map((task) => (
<div
key={task.id}
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
))}
</div>
)
}
3.4 CSS 轉場細節調整
/* globals.css — 轉場 duration + easing */
::view-transition-group(*) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* 個別元素調整 */
::view-transition-group(hero-image) {
animation-duration: 0.6s;
}
/* reduced-motion 下立即轉場 */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation-duration: 0.01ms;
}
}
4. Tier 3 — 自訂 Pointer Primitives 實作
不使用函式庫,直接以 Pointer Events API + 狀態機 (FSM) 實作。
4.1 建立 useDrag 鉤子
// src/presentation/shared/hooks/use-drag.ts
import { useCallback, useEffect, useReducer, useRef } from 'react'
// FSM 3 狀態: idle / dragging / dropping
type DragState =
| { kind: 'idle' }
| { kind: 'dragging'; startX: number; startY: number; offsetX: number; offsetY: number; pointerId: number }
| { kind: 'dropping'; offsetX: number; offsetY: number }
type DragAction =
| { type: 'start'; x: number; y: number; pointerId: number }
| { type: 'move'; x: number; y: number }
| { type: 'drop' }
| { type: 'resolved' }
| { type: 'cancel' }
function dragReducer(state: DragState, action: DragAction): DragState {
switch (action.type) {
case 'start':
return { kind: 'dragging', startX: action.x, startY: action.y, offsetX: 0, offsetY: 0, pointerId: action.pointerId }
case 'move':
if (state.kind !== 'dragging') return state
return { ...state, offsetX: action.x - state.startX, offsetY: action.y - state.startY }
case 'drop':
if (state.kind !== 'dragging') return state
return { kind: 'dropping', offsetX: state.offsetX, offsetY: state.offsetY }
case 'resolved':
case 'cancel':
return { kind: 'idle' }
}
}
type DragHandlers = {
onPointerDown: (e: React.PointerEvent) => void
onPointerMove: (e: React.PointerEvent) => void
onPointerUp: (e: React.PointerEvent) => void
onPointerCancel: (e: React.PointerEvent) => void
}
export function useDrag(): {
state: DragState
handlers: DragHandlers
ref: React.RefObject<HTMLElement | null>
resolve: () => void
} {
const [state, dispatch] = useReducer(dragReducer, { kind: 'idle' })
// ... 其餘實作略
}
4.2 觸控與滑鼠支援
使用 Pointer Events API,可同時支援觸控與滑鼠輸入:
export function DraggableItem({ item, onDrop }: { item: Item; onDrop: (id: string, x: number, y: number) => void }) {
const { state, handlers, resolve } = useDrag()
// Pointer capture 確保在拖曳過程中持續收到事件
const handlePointerDown = (e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId)
handlers.onPointerDown(e)
}
// ... 其餘事件處理
return (
<div
className="touch-none select-none" // 阻止行動裝置預設行為
onPointerDown={handlePointerDown}
onPointerMove={handlers.onPointerMove}
onPointerUp={(e) => {
if (state.kind === 'dragging') {
handlers.onPointerUp(e)
onDrop(item.id, state.offsetX, state.offsetY)
}
resolve()
}}
onPointerCancel={handlers.onPointerCancel}
style={{
transform: state.kind === 'dragging' ? `translate(${state.offsetX}px, ${state.offsetY}px)` : undefined,
transition: state.kind === 'idle' ? 'transform 0.2s ease' : undefined,
}}
>
{item.content}
</div>
)
}
4.3 可排序清單
結合拖曳狀態與陣列重新排序:
// src/presentation/shared/hooks/use-sortable.ts
import { useState, useCallback } from 'react'
import { arrayMove } from '@/presentation/shared/lib/array-move' // 純函數
export function useSortable<T extends { id: string }>(initialItems: T[]) {
const [items, setItems] = useState(initialItems)
const [draggedId, setDraggedId] = useState<string | null>(null)
const onDragStart = useCallback((id: string) => {
setDraggedId(id)
}, [])
const onDragOver = useCallback((targetId: string) => {
setItems((prev) => {
if (!draggedId || draggedId === targetId) return prev
const oldIndex = prev.findIndex((i) => i.id === draggedId)
const newIndex = prev.findIndex((i) => i.id === targetId)
return arrayMove(prev, oldIndex, newIndex)
})
}, [draggedId])
const onDragEnd = useCallback(() => {
setDraggedId(null)
}, [])
return { items, draggedId, onDragStart, onDragOver, onDragEnd }
}
4.4 置放目標區域 (Drop Zone)
// 接收拖曳元素的放置區域
function DropZone({ onDrop, children }: { onDrop: (itemId: string) => void; children: React.ReactNode }) {
const [isOver, setIsOver] = useState(false)
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setIsOver(true)
}
const handleDragLeave = () => setIsOver(false)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsOver(false)
const itemId = e.dataTransfer.getData('text/plain')
if (itemId) onDrop(itemId)
}
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed p-4 transition-colors ${isOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}`}
>
{children}
</div>
)
}
4.5 可調整尺寸的面板
// 使用 pointer events 實作可拖曳調整大小的面板分隔條
function ResizablePanel({ initialWidth = 300 }: { initialWidth?: number }) {
const [width, setWidth] = useState(initialWidth)
const isResizing = useRef(false)
const handlePointerDown = (e: React.PointerEvent) => {
isResizing.current = true
e.currentTarget.setPointerCapture(e.pointerId)
}
const handlePointerMove = (e: React.PointerEvent) => {
if (!isResizing.current) return
setWidth((w) => Math.max(200, Math.min(600, w + e.movementX)))
}
const handlePointerUp = () => {
isResizing.current = false
}
return (
<div className="flex">
<div style={{ width }} className="shrink-0 overflow-hidden">
{/* 面板內容 */}
</div>
<div
className="w-2 cursor-col-resize touch-none select-none hover:bg-blue-500 active:bg-blue-700"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
)
}
5. 錯誤處理與邊際情況
5.1 瀏覽器相容性檢查
function supportsViewTransition(): boolean {
return typeof document !== 'undefined' && 'startViewTransition' in document
}
5.2 prefers-reduced-motion 統一處理
每個動畫元件都必須考慮使用者輔助功能偏好。以下為建議的實作方式:
// 可在應用程式高層使用
function usePrefersReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(() =>
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
)
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches)
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}, [])
return prefersReduced
}
5.3 錯誤邊界與降級
function AnimationFallback({ children }: { children: React.ReactNode }) {
try {
return <>{children}</>
} catch (error) {
console.warn('動畫渲染失敗,顯示靜態內容', error)
return <>{children}</>
}
}
6. 標準覆蓋率空白
| 編號 | 描述 | 影響層級 | 建議替代方案 |
|---|---|---|---|
| 1 | scroll-driven 在 Firefox 156+ 的支援度 | T1 | 在 @supports 中提供 CSS 回退 |
| 2 | view-timeline 在 Safari 的支援度 | T2 | 使用 Intersection Observer 作為降級方案 |
| 3 | startViewTransition types 參數在 Firefox 的支援度 | T2 | 使用 try/catch 包裹 |
| 4 | React 19.3 以下版本無原生 ViewTransition | T2 | 僅使用 withViewTransition 包裝器 |
| 5 | 跨文件 View Transition 支援度有限 | T2 | 以 SPA 同文件轉場為主 |
| 6 | drag 事件在觸控裝置上的行為不一致 | T3 | 優先使用 Pointer Events API |
| 7 | 部分瀏覽器可能因 transform 導致子元素模糊 | T1/T2 | 使用 will-change: transform 或 backface-visibility: hidden |
| 8 | 內聯動畫在 Shadow DOM 中的行為不確定 | T1/T2/T3 | 避免在 Shadow DOM 中使用,建議由宿主元素處理 |