Motion Patterns
為最常見的 UI 動畫需求提供可複製貼上的模式。
此處的每個模式都基於 motion-foundations 的令牌和彈簧構建。
請勿在此定義新的持續時間或緩動值 — 請導入它們。
何時啟用
- 為按鈕、卡片、模態框或通知添加動畫
- 使用交錯效果建立列表項目的進場動畫
- 在 Next.js App Router 中設定頁面轉場
- 為條件式內容添加入場或出場動畫
- 實現滾動觸發、滾動關聯進度條或固定故事章節
- 建立展開卡片、手風琴或共享元素轉場
產出
此技能將產出:
- 所有標準 UI 組件的可訪問、SSR 安全的動畫
- 包裝於
AnimatePresence中的條件式渲染,並具有正確的退出行為 - 適用於 Next.js App Router 的頁面轉場包裝組件
- 使用
useScroll+useTransform的滾動觸發和滾動關聯模式 - 用於展開和交叉淡化的佈局動畫模式(
layout、layoutId)
原則
- 每個模式都從
motion-foundations導入。沒有原始數值。 - 每個條件式渲染都包裝在帶有
key的AnimatePresence中。 - 退出動畫始終與進入動畫一起定義 — 絕不能事後添加。
layout僅用於小幅的、孤立的移動。大型子樹使用明確的 transform。
規則
- 始終將條件式渲染包裝在帶有
key的AnimatePresence中,並作用於直接子元素。沒有 key,退出動畫永遠不會觸發。 - 定義
initial+animate時,始終同時定義exit。沒有退出動畫的動畫是不完整的。 - 在頁面轉場中使用
mode="wait"。進入必須在退出完成後才能開始。 - 切勿對超過約 5 個子元素或嵌套較深的 DOM 使用
layout。改用明確的x/y變換。 - 交錯間隔必須保持在
0.05s和0.10s之間。低於此值感覺機械化;高於此值感覺遲緩。 - 模態框必須始終包含: 焦點陷阱、按 Escape 鍵關閉、滾動鎖定、
role="dialog"、aria-modal="true"。 - 滾動觸發使用
viewport={{ once: true }}。在滾出時重複觸發會分散注意力,而非提供資訊。 - 所有令牌值都從
motion-foundations導入。沒有內聯數值。
決策指南
選擇正確的模式
| 情境 | 模式 |
|---|---|
| 元素出現 / 消失 | AnimatePresence |
| 列表項目按順序載入 | Stagger variants |
| 在路由之間導航 | Page transition wrapper |
| 元素在原地改變大小 | layout prop |
| 相同元素在頁面上下文間移動 | layoutId |
| 元素滾動到視野時出現 | whileInView |
| 數值與滾動位置相關聯 | useScroll + useTransform |
何時使用 mode="wait" 與 mode="sync"
| 模式 | 使用時機 |
|---|---|
wait | 頁面轉場、內容切換(一次一個) |
sync | 堆疊通知、列表項目(重疊沒問題) |
popLayout | 從需要重新排版的列表中移除項目 |
核心概念
AnimatePresence 合約
三件事必須始終為真:
AnimatePresence包裝了條件式內容- 直接子元素具有
key - 子元素具有
exit屬性
缺少任何一項,退出動畫都會靜默地失敗。
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}
/>
交錯列表
"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>
模態框
"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}
/>
{/* 面板 — 可訪問性要求:焦點陷阱、Escape 關閉、
滾動鎖定、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}
/>
</>
)
}
通知堆疊
"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>
)
}
滾動觸發
"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>
)
}
共享元素交叉淡化
// 來源上下文
<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" />
手風琴
<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>
端到端範例
一個在掛載時進入、處理條件式存在並尊重減少動畫設定的交錯列表 — 結合影音令牌、彈簧、AnimatePresence 以及來自 motion-foundations 的可訪問性鉤子:
"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>
)
}
約束 / 非目標
此技能不涵蓋:
- 令牌和彈簧定義 → 請參閱
motion-foundations - 拖曳互動、滑動手勢、可重新排序列表 → 請參閱
motion-advanced - 文字動畫(單字/字元揭示、計數器) → 請參閱
motion-advanced - SVG 路徑繪製或變形 → 請參閱
motion-advanced - 自訂動畫鉤子 → 請參閱
motion-advanced - 不使用
motion/react的純 CSS 轉場
反模式
| 反模式 | 違反的規則 | 修復方法 |
|---|---|---|
AnimatePresence 子元素缺少 key | 規則 1 | 為直接子元素添加穩定的 key |
有 initial + animate 但沒有 exit | 規則 2 | 始終將三者一起定義 |
頁面轉場沒有 mode="wait" | 規則 3 | 為 AnimatePresence 添加 mode="wait" |
在包含 50 個項目的列表上使用 layout | 規則 4 | 使用 mode="popLayout" 或明確的變換 |
在包含 10 個項目的列表上使用 staggerChildren: 0.2 | 規則 5 | 限制在 0.08–0.10 範圍內 |
| 模態框沒有焦點陷阱 | 規則 6 | 添加 focus-trap-react 或 Radix Dialog |
whileInView 沒有 viewport={{ once: true }} | 規則 7 | 重複進場會分散注意力,而非提供資訊 |
內聯 transition={{ duration: 0.3 }} | 規則 8 | 使用 motionTokens.duration.normal |
相關技能
motion-foundations— 定義此處每個模式所導入的所有令牌、彈簧、useSafeMotion鉤子和 SSR 防護。必須先設定好。motion-advanced— 以拖曳、手勢、SVG、文字、自訂鉤子和命令式序列擴展這些模式。不會重新定義此技能中的任何模式。