motion-ui

motion-ui

熱門

適用於 React / Next.js 的正式環境級 UI 動效系統。在實作動畫、轉場效果或各類動效模式時使用。

24萬星標
3.6萬分支
更新於 2026/8/2
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/reactframer-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