React: 过渡与动画
最后更新:2026-10-26
Tom 刚完成了一个数据仪表盘项目,产品经理反馈说页面切换太生硬——列表加载时数据"闪一下"就出来了,弹窗打开和关闭也是瞬间完成。Tom 意识到:功能完整不等于体验好。他需要给页面加上流畅的过渡动画。
1. 你将学到
- CSS Transition 和 Animation 的基础用法及区别
- Framer Motion 的 motion 组件与 AnimatePresence
- 进入/离开动画(mount/unmount 动画)
- 列表项目的批量动画(layout 动画)
- 手势交互动画(拖拽、悬停)
2. 概念图解
下面的图帮助你在选择动画方案时快速决策:
flowchart TD
A[需要动画效果] --> B{效果复杂度}
B -->|简单过渡| C[CSS Transition]
B -->|关键帧动画| D[CSS Animation]
B -->|复杂交互| E[Framer Motion]
C --> C1["hover / focus 效果"]
C --> C2["opacity / transform 渐变动画"]
C --> C3["单个属性变化"]
D --> D1["@keyframes 定义多阶段"]
D --> D2["无限循环动画"]
D --> D3["加载骨架屏"]
E --> E1["mount/unmount 动画"]
E --> E2["拖拽 / 手势"]
E --> E3["列表 layout 动画"]
E --> E4["Spring 物理动效"]
style C fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. 一个真实场景
Tom 的仪表盘包含三个核心交互:任务列表加载、模态弹窗开关和卡片拖拽排序。他的目标:
- 任务列表加载时,每条任务从左侧滑入
- 弹窗打开时背景淡入 + 弹窗缩放,关闭时反向动画
- 拖拽卡片时跟随手指,松手后弹性回位
他从最简单的 CSS transition 开始,逐步过渡到 Framer Motion 实现复杂动画。
(1) CSS Transition — 最简单的渐进增强
CSS Transition 适用于起始状态到结束状态之间的平滑过渡。它不需要任何 JavaScript 库,通过 CSS 声明即可生效。
Transition 的四个核心子属性
CSS
/* transition: property duration timing-function delay */
.example {
transition: opacity 0.3s ease 0s;
/* 完整写法:
transition-property: opacity
transition-duration: 0.3s
transition-timing-function: ease
transition-delay: 0s
*/
}
输出:
TEXT
CSS-in-JS 或作用域样式的样式化组件。动画过渡:元素平滑地淡入/滑动/移动
常用的 timing-function 值:
ease:慢 → 快 → 慢(默认)ease-in:慢 → 匀速ease-out:匀速 → 慢ease-in-out:两端慢、中间快linear:全程匀速cubic-bezier(n, n, n, n):自定义贝塞尔曲线
▶ 示例 1:CSS Transition 实现手风琴组件
▶ 试一试输出:
TEXT
淡入/淡出:opacity 在 300ms 内 0→1。切换按钮以平滑的 CSS 过渡显示/隐藏元素。
关键点: maxHeight + opacity + padding 同时过渡,实现内容区域的展开/收起效果。注意 maxHeight 值要略大于实际内容高度。
(2) CSS Animation — 关键帧驱动动画
当动画超过两个阶段(不止起始 → 结束),或者需要循环播放时,CSS Animation 是更好的选择。它通过 @keyframes 定义多阶段动画序列。
核心属性
CSS
/* animation: name duration timing-function delay iteration-count direction fill-mode */
.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
输出:
TEXT
骨架屏:加载时显示灰色动态占位块 → 获取完成后替换为真实内容
▶ 示例 2:骨架屏加载动画
JSX
import './Skeleton.css'
function Skeleton({ count = 3 }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-avatar" />
<div style={{ flex: 1 }}>
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '90%', marginTop: 8 }} />
</div>
</div>
))}
</div>
)
}
export default Skeleton
输出:
TEXT
骨架屏:数据加载时灰色占位块呈脉冲/微光效果 → 获取完成后替换为真实内容
CSS
/* Skeleton.css */
.skeleton-row {
display: flex;
gap: 12px;
align-items: center;
}
.skeleton-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
骨架屏动画的原理: 通过 background-position 的移动,模拟加载中流光扫过的视觉效果。CSS Animation 的 infinite 让流光不断循环,直到真实内容加载完成替换骨架屏。
(3) Framer Motion — 复杂的 React 动画
Framer Motion 是 React 生态中最强大的动画库,它提供了三个核心能力:
| 能力 | 解决的问题 | 对应 API |
|---|---|---|
| 进入/离开动画 | 组件 mount/unmount 时的动画 | motion.div + AnimatePresence |
| layout 动画 | 列表排序变化时的平滑过渡 | layout prop |
| 手势动画 | 拖拽、悬停、点击手势 | drag / whileHover / whileTap |
安装
BASH
npm install framer-motion
输出:
TEXT
使用 Framer Motion 的待办列表:添加/删除/重排均有动画。点击切换完成状态(删除线)。拖拽可重新排序条目
▶ 示例 3:Framer Motion 实现待办事项列表
JSX
import { useState } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'
const initialTodos = [
{ id: 1, text: '完成 React 动画学习', done: false },
{ id: 2, text: '重构仪表盘组件', done: false },
{ id: 3, text: '代码审查 PR #142', done: false },
{ id: 4, text: '更新项目文档', done: true },
{ id: 5, text: '修复导航栏响应式BUG', done: false },
]
function TodoList() {
const [todos, setTodos] = useState(initialTodos)
function toggleDone(id) {
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
)
}
function removeTodo(id) {
setTodos(prev => prev.filter(t => t.id !== id))
}
return (
<div style={{ maxWidth: 480, fontFamily: 'sans-serif' }}>
<AnimatePresence>
{todos.map(todo => (
<motion.div
key={todo.id}
layout
initial={{ opacity: 0, x: -60 }}
animate={{
opacity: 1,
x: 0,
background: todo.done ? '#f6ffed' : '#fff',
}}
exit={{ opacity: 0, x: 100, height: 0, marginBottom: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
marginBottom: 8,
borderRadius: 8,
border: '1px solid #f0f0f0',
cursor: 'pointer',
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleDone(todo.id)}
style={{ width: 18, height: 18, cursor: 'pointer' }}
/>
<span
style={{
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#999' : '#333',
fontSize: 15,
}}
>
{todo.text}
</span>
</div>
<button
onClick={() => removeTodo(todo.id)}
style={{
border: 'none',
background: 'none',
color: '#ff4d4f',
cursor: 'pointer',
fontSize: 18,
fontWeight: 'bold',
}}
>
x
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)
}
export default TodoList
输出:
TEXT
淡入/淡出:opacity 在 300ms 内 0→1。切换按钮以平滑的 CSS 过渡显示/隐藏元素。
输出:
TEXT
Framer Motion:带动画的进入/退出/布局过渡。使用 AnimatePresence 实现挂载/卸载动画
这段代码展示了 Framer Motion 四个关键特性:
initial/animate/exit:定义组件的进入、显示和离开状态。AnimatePresence使条件渲染也能触发退出动画layoutprop:当列表项因为done状态变化而改变位置时,会自动平滑过渡到新位置,不需要计算坐标transition:使用spring物理弹簧动效,stiffness控制弹簧刚度,damping控制阻尼(数值越大越不弹)whileHover/whileTap:交互手势动画,鼠标悬停时放大 1.02 倍,按下时缩小到 0.98 倍
▶ 示例 4:Framer Motion 模态弹窗动画
JSX
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
function Modal({ isOpen, onClose, children }) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={onClose}
>
<motion.div
key="modal"
initial={{ scale: 0.85, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.85, opacity: 0, y: 20 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
background: '#fff',
padding: 24,
borderRadius: 12,
minWidth: 320,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button
onClick={() => setIsOpen(true)}
style={{ padding: '8px 20px', fontSize: 15, cursor: 'pointer' }}
>
打开弹窗
</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2 style={{ margin: '0 0 12px' }}>确认操作</h2>
<p style={{ color: '#666', marginBottom: 20 }}>
你确定要删除这条记录吗?此操作无法撤销。
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button
onClick={() => setIsOpen(false)}
style={{ padding: '8px 20px', cursor: 'pointer' }}
>
取消
</button>
<button
onClick={() => {
alert('已删除')
setIsOpen(false)
}}
style={{
padding: '8px 20px',
background: '#ff4d4f',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
}}
>
确认删除
</button>
</div>
</Modal>
</div>
)
}
输出:
TEXT
淡入/淡出:opacity 在 300ms 内 0→1。切换按钮以平滑的 CSS 过渡显示/隐藏元素。
输出:
TEXT
骨架屏:加载时显示灰色动态占位块 → 获取完成后替换为真实内容
弹窗动画的设计要点:
- 遮罩层(overlay)和弹窗内容(modal)各用一套动画,但用
AnimatePresence统一管理 - 弹窗用
spring动画,stiffness: 400使其响应快速,damping: 30减少弹跳 - 进入时:弹窗从 0.85 倍缩放 + 20px 下方进入
- 退出时:反向缩放 + 向下移动,与进入动效对称
4. 动画性能最佳实践
| 属性 | 触发阶段 | 性能 | 推荐度 |
|---|---|---|---|
transform |
Composite | ✅ 最优 | ⭐⭐⭐ |
opacity |
Composite | ✅ 最优 | ⭐⭐⭐ |
filter |
Paint | ⚠️ 中等 | ⭐⭐ |
box-shadow |
Paint | ⚠️ 中等 | ⭐ |
width/height |
Layout | ❌ 昂贵 | ❌ |
top/left/margin |
Layout | ❌ 昂贵 | ❌ |
| 规则 | 说明 |
|---|---|
优先使用 transform 和 opacity |
这两个属性触发 compositor 线程,不引发 Layout/Paint |
避免动画 width / height / top / left |
这些属性触发 Layout 重排,消耗较大 |
使用 will-change 提示浏览器 |
will-change: transform, opacity 提前创建合成层 |
| 避免同时动画太多元素 | 超过 20 个元素同时动画可能导致掉帧 |
使用 requestAnimationFrame |
在 JS 驱动的动画中避免使用 setTimeout |
▶ 示例 5:CSS Keyframe 动画——加载骨架屏
▶ 试一试输出:
TEXT
无限循环:在 useEffect 中不带依赖直接调用 setState → 渲染 → effect → setState → 渲染…… 修复:添加依赖或使用空数组 []。
❓ 常见问题
Q CSS Transition 和 CSS Animation 到底有什么区别?
A Transition 是"从 A 到 B"的两阶段过渡,由状态变化触发(如 hover),不支持循环。Animation 是多阶段关键帧动画,可以无限循环、反向播放、暂停/继续。简单场景用 transition,复杂序列用 animation。
Q Framer Motion 的
AnimatePresence 为什么有时不起作用?A 最常见的原因是
key 没有正确设置。AnimatePresence 通过 key 追踪子元素,如果 key 不变,它会认为元素"更新"而非"移除",exit 动画不会触发。另外 AnimatePresence 的直接子元素必须是 motion 组件。Q 移动端动画性能如何保障?
A 坚持只动画
transform 和 opacity,这两个属性在移动端 GPU 合成层上运行。Framer Motion 默认使用 transform,性能有保障。如果必须在移动端做复杂动画,可以用 useWillChange hook 或手动设置 will-change。Q 动画导致页面布局偏移(Layout Shift)怎么办?
A 给动画元素预留固定尺寸,避免动画过程改变布局。Framer Motion 的
layout prop 可以用 layoutId 配合 AnimatePresence 实现布局稳定过渡。如果使用 CSS,考虑用 transform: translateX() 代替 left 做位移动画。Q React 18 的 useTransition 和 Framer Motion 有什么区别?
A
useTransition 是 React 内置的并发特性,标记某些状态更新为"非紧急"(如搜索结果过滤),让紧急更新(如输入框打字)优先响应,避免卡顿。Framer Motion 是动画库,处理 UI 元素的位移/缩放/透明度等视觉效果。两者解决不同问题:useTransition 解决"交互卡顿",Framer Motion 解决"视觉过渡"。📖 小节
- CSS Transition 适合简单的两阶段过渡,在状态变化时自动触发
- CSS Animation 配合
@keyframes可实现多阶段、循环的复杂时序动画 - Framer Motion 的
motion组件 +AnimatePresence覆盖了 React 组件所有动画场景 initial/animate/exit三个属性分别控制进入、显示、离开状态layoutprop 让列表重新排序时光滑过渡,无需手动计算坐标- 动画性能的核心原则:始终优先使用
transform和opacity
📝 作业
- 用 CSS Transition 实现一个"回到顶部"按钮:页面滚动超过 300px 时淡入显示,点击后平滑滚动到顶部,回到顶部后淡出隐藏。
- 用 CSS Animation 实现一个加载 Spinner:圆环旋转动画,
@keyframes控制 0deg 到 360deg 旋转,速度曲线用linear保持匀速。 - 用 Framer Motion 实现一个可拖拽的浮动按钮(FAB),
dragprop 允许它在屏幕内任意拖拽,拖拽结束时用spring动效吸附到最近的屏幕边缘。