动画模式
针对最常见 UI 动画需求的复制粘贴方案。
这里的每一个模式都基于 motion-foundations 的令牌和弹簧构建。
请勿在此定义新的时长或缓动值——请直接导入它们。
何时激活
- 为按钮、卡片、模态框或通知添加动画
- 构建带逐项显示的列表入场
- 在 Next.js App Router 中设置页面过渡
- 为条件渲染内容添加入场或出场动画
- 实现滚动揭示、滚动关联进度或粘性故事章节
- 构建展开卡片、手风琴或共享元素过渡
输出
此技能可生成:
- 符合无障碍标准、SSR 安全的所有标准 UI 组件动画
- 包裹了
AnimatePresence的条件渲染,并包含正确的退出行为 - 用于 Next.js App Router 的页面过渡包装组件
- 使用
useScroll+useTransform的滚动揭示和滚动关联模式 - 用于展开和交叉淡入元素的布局动画模式(
layout、layoutId)
原则
- 每个模式都从
motion-foundations导入。不允许使用原始数字。 - 每个条件渲染都必须用
AnimatePresence包裹,并附带一个key。 - 退出动画始终与入场动画一起定义——绝不能事后添加。
layout仅用于小型、孤立的尺寸变化。大型子树将使用显式变换。
规则
- 始终将条件渲染包裹在带有
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 |
| 列表项按顺序加载 | 逐项变体 |
| 在路由间导航 | 页面过渡包装器 |
| 元素在原地改变大小 | layout 属性 |
| 同一元素在不同页面上下文中移动 | 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}>移除</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、文本、自定义钩子和命令式排序扩展了这些模式。不会重新定义此技能中的任何模式。