#在 React 中组件间过渡动画如何实现?
#一、是什么
过渡动画是用户界面中不可或缺的一部分,它能为用户提供视觉反馈、引导注意力、传达状态变化。在 React 中,由于组件的挂载/卸载是声明式的,实现动画——尤其是退出动画——需要特殊处理。
React 生态提供了多种动画解决方案,从原生 CSS 过渡到功能强大的动画库,每种方案适用于不同的场景和复杂度需求。
#二、CSS Transitions / Animations
最简单的动画方式是利用 CSS 的 transition 和 animation 属性,配合 React 的条件渲染和状态切换:
#CSS Transition
import { useState } from 'react';
function FadeToggle() {
const [visible, setVisible] = useState(true);
return (
<div>
<button onClick={() => setVisible(v => !v)}>切换</button>
<div
style={{
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0)' : 'translateY(-20px)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
}}
>
<p>这段内容会淡入淡出</p>
</div>
</div>
);
}#CSS @keyframes
/* animations.module.css */
.slideIn {
animation: slideIn 0.3s ease forwards;
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}import styles from './animations.module.css';
function AnimatedList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item, index) => (
<li
key={item}
className={styles.slideIn}
style={{ animationDelay: `${index * 0.1}s` }}
>
{item}
</li>
))}
</ul>
);
}局限性: 纯 CSS 方案难以处理组件卸载(退出)动画,因为 React 会立即从 DOM 中移除元素。
#三、react-transition-group
react-transition-group 是 React 官方推荐的过渡动画基础库,它不提供动画效果本身,而是管理组件在进入/退出过程中的状态,配合 CSS 实现动画。
#CSSTransition
/* fade.css */
.fade-enter {
opacity: 0;
transform: scale(0.95);
}
.fade-enter-active {
opacity: 1;
transform: scale(1);
transition: opacity 300ms ease, transform 300ms ease;
}
.fade-exit {
opacity: 1;
transform: scale(1);
}
.fade-exit-active {
opacity: 0;
transform: scale(0.95);
transition: opacity 300ms ease, transform 300ms ease;
}import { useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import './fade.css';
function AlertMessage() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(s => !s)}>
{show ? '隐藏' : '显示'}提示
</button>
<CSSTransition
in={show}
timeout={300}
classNames="fade"
unmountOnExit
>
<div className="alert">
<p>这是一条带动画的提示信息</p>
</div>
</CSSTransition>
</div>
);
}CSSTransition 的关键属性:
in:控制进入/退出状态timeout:动画持续时间(ms)classNames:CSS 类名前缀unmountOnExit:退出动画结束后卸载组件mountOnEnter:首次in=true时才挂载appear:首次挂载时是否执行进入动画
#TransitionGroup——列表动画
TransitionGroup 管理一组 CSSTransition,自动处理子元素的添加和移除动画:
/* list-item.css */
.item-enter {
opacity: 0;
transform: translateX(-20px);
}
.item-enter-active {
opacity: 1;
transform: translateX(0);
transition: all 300ms ease;
}
.item-exit {
opacity: 1;
transform: translateX(0);
}
.item-exit-active {
opacity: 0;
transform: translateX(20px);
transition: all 300ms ease;
}import { useState } from 'react';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import './list-item.css';
function AnimatedTodoList() {
const [items, setItems] = useState([
{ id: 1, text: '学习 React' },
{ id: 2, text: '学习 TypeScript' },
]);
const addItem = () => {
const newItem = {
id: Date.now(),
text: `新任务 ${items.length + 1}`,
};
setItems(prev => [...prev, newItem]);
};
const removeItem = (id: number) => {
setItems(prev => prev.filter(item => item.id !== id));
};
return (
<div>
<button onClick={addItem}>添加任务</button>
<TransitionGroup component="ul">
{items.map(item => (
<CSSTransition key={item.id} timeout={300} classNames="item">
<li>
{item.text}
<button onClick={() => removeItem(item.id)}>删除</button>
</li>
</CSSTransition>
))}
</TransitionGroup>
</div>
);
}#SwitchTransition——组件切换动画
import { useState } from 'react';
import { SwitchTransition, CSSTransition } from 'react-transition-group';
function ToggleContent() {
const [isOn, setIsOn] = useState(false);
return (
<div>
<SwitchTransition mode="out-in">
<CSSTransition key={isOn ? 'on' : 'off'} timeout={200} classNames="fade">
<button onClick={() => setIsOn(v => !v)}>
{isOn ? '开启' : '关闭'}
</button>
</CSSTransition>
</SwitchTransition>
</div>
);
}#四、Framer Motion
Framer Motion 是目前 React 生态中最流行的动画库,API 设计直观,功能强大。
#基础动画
import { motion } from 'framer-motion';
function MotionCard() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
whileHover={{ scale: 1.02, boxShadow: '0 8px 30px rgba(0,0,0,0.12)' }}
whileTap={{ scale: 0.98 }}
style={{
padding: 24,
borderRadius: 12,
background: 'white',
border: '1px solid #eee',
}}
>
<h3>Framer Motion 动画卡片</h3>
<p>支持手势、弹簧动画、布局动画等</p>
</motion.div>
);
}#AnimatePresence——退出动画
AnimatePresence 解决了 React 中最棘手的退出动画问题:
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
function Notifications() {
const [notifications, setNotifications] = useState<
{ id: number; text: string }[]
>([]);
const addNotification = () => {
setNotifications(prev => [
...prev,
{ id: Date.now(), text: `通知 ${prev.length + 1}` },
]);
};
const removeNotification = (id: number) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
return (
<div>
<button onClick={addNotification}>添加通知</button>
<div style={{ position: 'fixed', top: 20, right: 20 }}>
<AnimatePresence>
{notifications.map(notification => (
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
style={{
padding: '12px 20px',
marginBottom: 8,
borderRadius: 8,
background: '#1890ff',
color: 'white',
cursor: 'pointer',
}}
onClick={() => removeNotification(notification.id)}
>
{notification.text}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}#布局动画
Framer Motion 的 layout 属性可以自动为元素的布局变化添加平滑过渡:
import { useState } from 'react';
import { motion, LayoutGroup } from 'framer-motion';
interface Tab {
id: string;
label: string;
}
const tabs: Tab[] = [
{ id: 'home', label: '首页' },
{ id: 'about', label: '关于' },
{ id: 'contact', label: '联系' },
];
function AnimatedTabs() {
const [activeTab, setActiveTab] = useState('home');
return (
<LayoutGroup>
<div style={{ display: 'flex', gap: 4, position: 'relative' }}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
padding: '8px 16px',
position: 'relative',
background: 'transparent',
border: 'none',
cursor: 'pointer',
}}
>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="activeTab"
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
background: '#1890ff',
borderRadius: 3,
}}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
</button>
))}
</div>
</LayoutGroup>
);
}#页面过渡
import { AnimatePresence, motion } from 'framer-motion';
import { useLocation, Routes, Route } from 'react-router-dom';
const pageVariants = {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
};
function AnimatedRoutes() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route
path="/"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<HomePage />
</motion.div>
}
/>
<Route
path="/about"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<AboutPage />
</motion.div>
}
/>
</Routes>
</AnimatePresence>
);
}#五、React Spring
React Spring 基于弹簧物理模型实现动画,动画效果更加自然流畅:
import { useSpring, animated, useTrail } from '@react-spring/web';
function SpringCard() {
const [flipped, setFlipped] = useState(false);
const spring = useSpring({
transform: `perspective(600px) rotateY(${flipped ? 180 : 0}deg)`,
config: { mass: 1, tension: 200, friction: 20 },
});
return (
<animated.div
onClick={() => setFlipped(f => !f)}
style={{
...spring,
width: 200,
height: 260,
borderRadius: 12,
background: flipped ? '#52c41a' : '#1890ff',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 20,
}}
>
{flipped ? '背面' : '正面'}
</animated.div>
);
}
function TrailList({ items }: { items: string[] }) {
const trail = useTrail(items.length, {
from: { opacity: 0, transform: 'translateY(20px)' },
to: { opacity: 1, transform: 'translateY(0px)' },
});
return (
<ul>
{trail.map((style, index) => (
<animated.li key={items[index]} style={style}>
{items[index]}
</animated.li>
))}
</ul>
);
}#六、自定义 Hook 封装动画逻辑
import { useState, useCallback } from 'react';
function useAnimatedVisibility(duration = 300) {
const [isVisible, setIsVisible] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
const show = useCallback(() => {
setShouldRender(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsVisible(true);
});
});
}, []);
const hide = useCallback(() => {
setIsVisible(false);
const timer = setTimeout(() => {
setShouldRender(false);
}, duration);
return () => clearTimeout(timer);
}, [duration]);
return { isVisible, shouldRender, show, hide };
}
function Tooltip({ text, children }: { text: string; children: React.ReactNode }) {
const { isVisible, shouldRender, show, hide } = useAnimatedVisibility(200);
return (
<div onMouseEnter={show} onMouseLeave={hide} style={{ position: 'relative', display: 'inline-block' }}>
{children}
{shouldRender && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: `translateX(-50%) translateY(${isVisible ? '-8px' : '0px'})`,
opacity: isVisible ? 1 : 0,
transition: 'all 200ms ease',
padding: '4px 8px',
borderRadius: 4,
background: '#333',
color: 'white',
fontSize: 12,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{text}
</div>
)}
</div>
);
}#七、方案对比
| 特性 | CSS 过渡 | react-transition-group | Framer Motion | React Spring |
|---|---|---|---|---|
| 包体积 | 0 | ~6KB | ~30KB | ~20KB |
| 学习成本 | 低 | 低 | 中 | 中 |
| 退出动画 | ❌ 困难 | ✅ | ✅ | ⚠️ 需手动处理 |
| 布局动画 | ❌ | ❌ | ✅ | ❌ |
| 手势支持 | ❌ | ❌ | ✅ | ❌ |
| 物理动画 | ❌ | ❌ | ✅ 弹簧 | ✅ 弹簧 |
| SSR 支持 | ✅ | ✅ | ✅ | ✅ |
| 适用场景 | 简单过渡 | 进出场动画 | 复杂交互动画 | 物理模拟动画 |
#八、总结
React 动画方案的选择取决于项目需求的复杂度:
- 简单过渡效果(hover、显示隐藏):使用 CSS transitions
- 列表进出场动画:使用 react-transition-group
- 复杂交互动画(手势、布局变化、页面切换):使用 Framer Motion
- 物理模拟动画(弹簧、惯性):使用 React Spring
在大多数现代 React 项目中,Framer Motion 因其 API 的直观性和功能的全面性,已经成为最受欢迎的选择。对于简单场景,CSS 原生方案足以胜任,无需引入额外依赖。