React: 过渡与动画
最后更新:2026-08-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
*/
}
常用的 timing-function 值:
ease:慢 → 快 → 慢(默认)ease-in:慢 → 匀速ease-out:匀速 → 慢ease-in-out:两端慢、中间快linear:全程匀速cubic-bezier(n, n, n, n):自定义贝塞尔曲线
▶ 示例 1:CSS Transition 实现手风琴组件
JSX
📖 仅展示
import { useState } from 'react'
const accordionData = [
{ title: '什么是 React?', content: 'React 是一个用于构建用户界面的 JavaScript 库。' },
{ title: '什么是组件?', content: '组件是 React 应用的最小独立单元,可以复用和组合。' },
{ title: '什么是 State?', content: 'State 是组件内部的可变数据,驱动 UI 更新。' },
]
function Accordion() {
const [openIndex, setOpenIndex] = useState(null)
function toggle(index) {
setOpenIndex(openIndex === index ? null : index)
}
return (
<div style={{ maxWidth: 500, fontFamily: 'sans-serif' }}>
{accordionData.map((item, index) => (
<div
key={index}
style={{
border: '1px solid #e8e8e8',
borderRadius: 8,
marginBottom: 8,
overflow: 'hidden',
}}
>
<button
onClick={() => toggle(index)}
style={{
width: '100%',
padding: '12px 16px',
background: openIndex === index ? '#e6f7ff' : '#fafafa',
border: 'none',
cursor: 'pointer',
fontSize: 15,
fontWeight: 600,
textAlign: 'left',
transition: 'background 0.2s ease',
}}
>
{item.title}
</button>
<div
style={{
maxHeight: openIndex === index ? 80 : 0,
padding: openIndex === index ? '12px 16px' : '0 16px',
opacity: openIndex === index ? 1 : 0,
transition: 'all 0.3s ease',
color: '#666',
fontSize: 14,
lineHeight: 1.6,
}}
>
{item.content}
</div>
</div>
))}
</div>
)
}
关键点: 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; }
}
▶ 示例 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
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
▶ 示例 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
这段代码展示了 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>
)
}
弹窗动画的设计要点:
- 遮罩层(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 动画——加载骨架屏
JSX
function SkeletonCard() {
return (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 16, overflow: 'hidden' }}>
<div style={{
height: 120, borderRadius: 4, marginBottom: 12,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<div style={{
height: 16, width: '60%', borderRadius: 4, marginBottom: 8,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<div style={{
height: 12, width: '40%', borderRadius: 4,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<style>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
function LoadingGrid() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, maxWidth: 800, margin: '0 auto' }}>
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
</div>
)
}
❓ 常见问题
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动效吸附到最近的屏幕边缘。