在 React 中组件间过渡动画如何实现?

一、是什么

过渡动画是用户界面中不可或缺的一部分,它能为用户提供视觉反馈、引导注意力、传达状态变化。在 React 中,由于组件的挂载/卸载是声明式的,实现动画——尤其是退出动画——需要特殊处理。

React 生态提供了多种动画解决方案,从原生 CSS 过渡到功能强大的动画库,每种方案适用于不同的场景和复杂度需求。

二、CSS Transitions / Animations

最简单的动画方式是利用 CSS 的 transitionanimation 属性,配合 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-groupFramer MotionReact Spring
包体积0~6KB~30KB~20KB
学习成本
退出动画❌ 困难⚠️ 需手动处理
布局动画
手势支持
物理动画✅ 弹簧✅ 弹簧
SSR 支持
适用场景简单过渡进出场动画复杂交互动画物理模拟动画

八、总结

React 动画方案的选择取决于项目需求的复杂度:

  1. 简单过渡效果(hover、显示隐藏):使用 CSS transitions
  2. 列表进出场动画:使用 react-transition-group
  3. 复杂交互动画(手势、布局变化、页面切换):使用 Framer Motion
  4. 物理模拟动画(弹簧、惯性):使用 React Spring

在大多数现代 React 项目中,Framer Motion 因其 API 的直观性和功能的全面性,已经成为最受欢迎的选择。对于简单场景,CSS 原生方案足以胜任,无需引入额外依赖。