motion-patterns

motion-patterns

熱門

專為 React / Next.js 打造的開箱即用動畫模式 — 包含按鈕、彈窗、Toast 通知、交錯動畫、頁面切換、退場動畫、捲動與版面配置 — 基於 motion-foundations 的 Token 與彈簧參數構建。

24萬星標
3.6萬分支
更新於 2026/8/3
SKILL.md
唯讀
名稱
motion-patterns
描述

專為 React / Next.js 打造的開箱即用動畫模式 — 包含按鈕、彈窗、Toast 通知、交錯動畫、頁面切換、退場動畫、捲動與版面配置 — 基於 motion-foundations 的 Token 與彈簧參數構建。

版本
1.0

Motion Patterns

適用於最常見 UI 動態需求的即用型模式。
此處的每個模式皆建構於 motion-foundations 的 Token 與彈簧參數之上。
請勿在此處定義新的持續時間(Duration)或緩動(Easing)數值 — 請直接匯入使用。

啟用時機

  • 為按鈕、卡片、彈窗(Modal)或 Toast 通知加上動畫
  • 建立具有交錯效果(Stagger)的清單登場動畫
  • 在 Next.js App Router 中設定頁面轉場效果
  • 為條件渲染的內容新增進場或退場動畫
  • 實現捲動顯現(Scroll-reveal)、捲動聯動進度條或黏性故事區塊
  • 打造可展開卡片、摺疊面板(Accordion)或共享元素轉場(Shared-element transitions)

產出內容

本 Skill 提供:

  • 符合無障礙規範(Accessible)且支援 SSR 的所有標準 UI 元件動畫
  • AnimatePresence 包覆並具備正確退場行為的條件式渲染
  • 適用於 Next.js App Router 的頁面轉場包覆元件
  • 使用 useScroll + useTransform 的捲動顯現與捲動聯動模式
  • 用於元素展開與淡入淡出切換的版面動畫模式(layoutlayoutId

設計原則

  • 所有模式皆從 motion-foundations 匯入設定,絕不使用硬編碼數字(Raw numbers)。
  • 每個條件渲染都必須用 AnimatePresence 包覆並指定 key
  • 退場動畫必須與進場動畫一同定義,絕不能事後才補上。
  • layout 僅用於小型且獨立的位移。大型子樹應使用明確的 Transform 轉換。

核心規則

  1. 條件渲染必須以 AnimatePresence 包覆,且直接子元素必須設定 key 若缺少 key,退場動畫將永遠無法觸發。
  2. 定義 initial + animate 時,務必一併定義 exit 缺乏退場動畫的設定是不完整的。
  3. 頁面轉場請使用 mode="wait" 進場動畫必須等待退場動畫完全結束後才能開始。
  4. 切勿在包含超過 ~5 個子元素或深層巢狀 DOM 的子樹上使用 layout 請改用明確的 x/y Transform。
  5. 交錯間隔(Stagger interval)必須保持在 0.05s0.10s 之間。 低於此範圍會顯得機械化,高於此範圍則顯得拖沓。
  6. 彈窗(Modal)必須包含: 焦點鎖定(Focus trap)、Esc 鍵關閉、捲動鎖定、role="dialog"aria-modal="true"
  7. 捲動顯現請使用 viewport={{ once: true }} 捲出視窗時重複播放動畫只會干擾視覺,無法傳達有效資訊。
  8. 所有 Token 數值皆需從 motion-foundations 匯入。 請勿使用行內硬編碼數字。

決策指南

選擇適當的模式

情境 模式
元素出現 / 消失 AnimatePresence
項目清單依序載入 交錯變態(Stagger variants)
在路由之間導覽切換 頁面轉場包覆元件(Page transition wrapper)
元素在原地變更尺寸 layout prop
同一元素跨頁面上下文移動 layoutId
元素捲動進入視窗時進場 whileInView
數值與捲動位置聯動 useScroll + useTransform

何時使用 mode="wait"mode="sync"

模式 適用時機
wait 頁面轉場、內容替換(一次一個)
sync 堆疊通知、清單項目(重疊無妨)
popLayout 從會引發重新排版的清單中移除項目

核心概念

AnimatePresence 的必要條件

必須同時滿足以下三點:

  1. AnimatePresence 包覆條件判斷
  2. 直接子元素具有 key
  3. 子元素設定了 exit prop

遺漏任何一項,退場動畫都會靜默失效。

layout 與 layoutId 的差異

  • layout — 在原地為元素自身的尺寸/位置變更套用動畫
  • layoutId — 連接兩個獨立元素,在跨渲染時於兩者之間進行淡入淡出切換

在可展開容器內的文字上使用 layout="position",可防止文字重新排版時觸發不必要的動畫。

程式碼範例

按鈕反饋

"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"

<motion.button
  whileHover={{ scale: motionTokens.scale.pop }}
  whileTap={{ scale: motionTokens.scale.press }}
  transition={springs.snappy}
/>

交錯清單(Stagger list)

"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

const container = {
  hidden: {},
  visible: {
    transition: {
      staggerChildren: 0.08,   // 符合 0.05–0.10 秒規則
      delayChildren: 0.1,
    },
  },
}

const item = {
  hidden:  { opacity: 0, y: motionTokens.distance.md },
  visible: { opacity: 1, y: 0, transition: springs.gentle },
}

<motion.ul variants={container} initial="hidden" animate="visible">
  {items.map((i) => (
    <motion.li key={i.id} variants={item} />
  ))}
</motion.ul>

彈窗(Modal)

"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

// 呼叫處包覆方式:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>

export function Modal({ onClose }: { onClose: () => void }) {
  return (
    <>
      {/* 背景遮罩 */}
      <motion.div
        className="fixed inset-0 bg-black/50"
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        onClick={onClose}
      />

      {/* 主面板 — 無障礙需求:焦點鎖定、Esc 關閉、
          捲動鎖定、role="dialog"、aria-modal="true" */}
      <motion.div
        role="dialog"
        aria-modal="true"
        className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
        initial={{
          opacity: 0,
          scale: motionTokens.scale.press,
          y: motionTokens.distance.sm,
        }}
        animate={{ opacity: 1, scale: 1, y: 0 }}
        exit={{
          opacity: 0,
          scale: motionTokens.scale.press,
          y: motionTokens.distance.sm,
        }}
        transition={springs.gentle}
      />
    </>
  )
}

Toast 堆疊通知

"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

<AnimatePresence mode="sync">
  {toasts.map((t) => (
    <motion.div
      key={t.id}
      layout
      initial={{
        opacity: 0,
        x: motionTokens.distance.xl,
        scale: motionTokens.scale.subtle,
      }}
      animate={{ opacity: 1, x: 0, scale: 1 }}
      exit={{
        opacity: 0,
        x: motionTokens.distance.xl,
        scale: motionTokens.scale.subtle,
      }}
      transition={springs.snappy}
    />
  ))}
</AnimatePresence>

頁面轉場(Next.js App Router)

// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"

const variants = {
  initial: { opacity: 0, y: motionTokens.distance.sm },
  enter:   { opacity: 1, y: 0 },
  exit:    { opacity: 0, y: -motionTokens.distance.sm },
}

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname()
  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        variants={variants}
        initial="initial"
        animate="enter"
        exit="exit"
        transition={{
          duration: motionTokens.duration.normal,
          ease: motionTokens.easing.smooth,
        }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  )
}

捲動顯現(Scroll reveal)

"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

<motion.div
  initial={{ opacity: 0, y: motionTokens.distance.lg }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, margin: "-80px" }}   // once: true — 規則 7
  transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>

捲動進度條

"use client"
import { motion, useScroll } from "motion/react"

export function ScrollProgress() {
  const { scrollYProgress } = useScroll()
  return (
    <motion.div
      className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
      style={{ scaleX: scrollYProgress }}
    />
  )
}

可展開卡片

"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"

export function ExpandingCard({ title, body }: { title: string; body: string }) {
  const [expanded, setExpanded] = useState(false)

  return (
    <motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
      {/* layout="position" 可防止文字重新排版時引發動畫 */}
      <motion.h2 layout="position" className="font-semibold">
        {title}
      </motion.h2>

      <AnimatePresence>
        {expanded && (
          <motion.p
            key="body"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: motionTokens.duration.fast }}
          >
            {body}
          </motion.p>
        )}
      </AnimatePresence>
    </motion.div>
  )
}

共享元素轉場淡入淡出(Shared-element crossfade)

// 來源上下文
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />

// 目標上下文(相同的 layoutId — 由 motion 處理轉場)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />

摺疊面板(Accordion)

<motion.div
  initial={false}
  animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
  style={{ transformOrigin: "top", overflow: "hidden" }}
  transition={{
    duration: motionTokens.duration.normal,
    ease: motionTokens.easing.smooth,
  }}
> {children}
</motion.div>

完整端到端範例

在元件載入時以交錯動畫登場的清單,具備條件渲染處理並支援減弱動態(Reduced motion)設定 — 結合了 motion-foundations 的 Token、Spring、AnimatePresence 以及無障礙 Hook:

"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"

const containerVariants = {
  hidden: {},
  visible: {
    transition: { staggerChildren: 0.08, delayChildren: 0.1 },
  },
}

function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
  const safe = useSafeMotion(motionTokens.distance.sm)
  return (
    <motion.li
      variants={{
        hidden:  safe.initial,
        visible: safe.animate,
      }}
      exit={safe.exit}
      transition={springs.gentle}
      className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
    >
      <span>{label}</span>
      <button onClick={onRemove}>Remove</button>
    </motion.li>
  )
}

export function AnimatedList({ items, onRemove }: {
  items: { id: string; label: string }[]
  onRemove: (id: string) => void
}) {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
      className="space-y-2"
    >
      <AnimatePresence mode="popLayout">
        {items.map((item) => (
          <ListItem
            key={item.id}
            label={item.label}
            onRemove={() => onRemove(item.id)}
          />
        ))}
      </AnimatePresence>
    </motion.ul>
  )
}

範圍限制 / 非本 Skill 目標

本 Skill 不包含 以下內容:

  • Token 與 Spring 定義 → 請參閱 motion-foundations
  • 拖曳互動、滑動手勢、可排序清單 → 請參閱 motion-advanced
  • 文字動畫(單字/字元逐字顯現、計數器) → 請參閱 motion-advanced
  • SVG 路徑繪製或變形 → 請參閱 motion-advanced
  • 自訂動畫 Hook → 請參閱 motion-advanced
  • 純 CSS 轉場

<!-- truncated for translation batch; full body continues in source -->