
关于
面向 React / Next.js 的生产级动画模式——按钮、模态框、Toast、交错动画、页面过渡、退出动画、滚动和布局动画——基于 motion-foundations 令牌和弹簧系统构建。
name: motion-patterns description: 用于 React / Next.js 的生产就绪动画模式——按钮、模态框、Toast、交错动画、页面过渡、退出动画、滚动和布局——基于 motion-foundations token 和弹簧系统构建。 version: 1.0 tags: [motion, animation, ui-patterns] category: frontend author: jeff
Motion 动画模式
为最常见的 UI 动画需求提供可复制粘贴的模式。
这里的每个模式都基于 motion-foundations token 和弹簧系统构建。
不要在此定义新的持续时间或缓动值——从基础库导入。
何时激活
- 为按钮、卡片、模态框或 Toast 通知添加动画
- 构建带交错效果的列表入场动画
- 在 Next.js App Router 中设置页面过渡
- 为条件渲染内容添加入场或退出动画
- 实现滚动显示、滚动关联进度或粘性故事区段
- 构建展开卡片、手风琴或共享元素过渡
输出
本技能产出:
- 可访问的、SSR 安全的所有标准 UI 组件动画
- 使用
AnimatePresence包裹的条件渲染,具有正确的退出行为 - 用于 Next.js App Router 的页面过渡包装组件
- 使用
useScroll+useTransform的滚动显示和滚动关联模式 - 用于展开和交叉淡入淡出元素的布局动画模式(
layout、layoutId)
原则
- 每个模式都从
motion-foundations导入。不使用原始数字。 - 每个条件渲染都用带
key的AnimatePresence包裹。 - 退出动画始终与入场动画一起定义——绝不作为事后补充。
layout仅用于小型、隔离的位移。大型子树使用显式 transform。
规则
- 始终用带
key的AnimatePresence包裹条件渲染,key 放在直接子元素上。没有 key,退出动画永远不会触发。 - 定义
initial+animate时始终定义exit。 没有退出的动画是不完整的。 - 页面过渡使用
mode="wait"。 入场必须等退出完成后才开始。 - 不要在超过约 5 个子元素或深层嵌套 DOM 的子树上使用
layout。 改用显式x/ytransform。 - 交错间隔必须保持在
0.05s到0.10s之间。 低于此值感觉机械;高于此值感觉迟缓。 - 模态框必须始终包含: 焦点陷阱、Escape 键关闭、滚动锁定、
role="dialog"、aria-modal="true"。 - 滚动显示使用
viewport={{ once: true }}。 滚出时重复播放是干扰,而非信息传达。 - 所有 token 值从
motion-foundations导入。 不使用内联数字。
决策指南
选择正确的模式
| 场景 | 模式 |
| ---------------------------------------- | ---------------------- |
| 元素出现/消失 | AnimatePresence |
| 列表项按顺序加载 | 交错 variants |
| 路由间导航 | 页面过渡包装器|
| 元素在原位改变大小 | layout 属性 |
| 同一元素跨页面上下文移动 | layoutId |
| 元素滚动到视口时入场 | whileInView |
| 值与滚动位置关联 | useScroll + useTransform |
何时使用 mode="wait" vs mode="sync"
| 模式 | 使用场景 |
| ------- | --------------------------------------- |
| wait | 页面过渡、内容切换(一次一个) |
| sync | 堆叠通知、列表项(重叠无妨) |
| popLayout | 从重排列表中移除的项 |
核心概念
AnimatePresence 契约
三件事必须始终为真:
AnimatePresence包裹条件渲染- 直接子元素有
key - 子元素有
exit属性
遗漏任何一项,退出动画都会静默失败。
layout vs 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"
