首页
/ Material UI 过渡动画(Transitions)完整指南:Collapse、Fade、Grow、Slide、Zoom 与 Reduced Motion 深度解析

Material UI 过渡动画(Transitions)完整指南:Collapse、Fade、Grow、Slide、Zoom 与 Reduced Motion 深度解析

2026-09-04 19:43:42作者:平淮齐Percy

Material UI 内置了五个过渡组件——Collapse、Fade、Grow、Slide、Zoom,它们是 Modal、Dialog、Drawer、Snackbar 等组件实现开合动效的底层基础。本文基于仓库中的过渡组件文档(docs/data/material/components/transitions/transitions.md)并结合 packages/mui-material/src 下的真实源码,系统讲解每个过渡组件的用法与关键参数、Reduced Motion 无障碍支持的实现机制、子元素转发 ref/style 的硬性要求,以及如何通过 transition slot 自定义组件内部过渡,帮助你既能直接复用官方过渡组件,也能理解其内部原理并安全地扩展它。

五大过渡组件速览

Material UI 的过渡组件让界面"富有表现力且易于使用",用于给应用引入基础的动效(motion)。五个组件分别对应不同的视觉语义:

组件 视觉行为 典型场景
Collapse 从子元素起始边缘展开/收起(垂直或水平) 手风琴、列表增删、可折叠面板
Fade 从透明淡入到不透明 提示、遮罩、轻量内容
Grow 从中心向外放大,同时淡入 强调式入场
Slide 从屏幕边缘滑入 Snackbar、Drawer、浮层
Zoom 从中心向外放大 Popover、气泡、强调式入场

所有过渡组件共享同一套来自 transition 类型定义 的 props,包括:

  • in:控制过渡是否处于"进入"状态(对应打开/关闭);
  • mountOnEnterintrue 之前子元素不挂载;
  • unmountOnExit:过渡完全移出屏幕后把组件从 DOM 中移除;
  • timeout:过渡时长(毫秒),可以是单一数值,也可以是 { appear, enter, exit } 对象分别指定;
  • easing:过渡缓动函数,可以是字符串,也可以是 { enter, exit } 对象;
  • addEndListener:自定义"过渡结束"触发器(用于需要自定义判断逻辑的场景;如果同时提供了 timeout,timeout 仍会作为兜底);
  • disablePrefersReducedMotion:为 true 时忽略 theme.motion.reducedMotion,保持正常动效;
  • 以及 onEnter / onEntering / onEntered / onExit / onExiting / onExited 六个生命周期回调(对应 TransitionHandlerKeys 类型)。

Collapse:从边缘展开的折叠动画

Collapse 从子元素的起始边缘展开。需要水平折叠时传入 orientation prop;collapsedSize prop 用于设置未展开时的最小宽度/高度。

仓库中的示例 SimpleCollapse.js 展示了垂直/水平两种方向与 collapsedSize={40} 的对比用法:

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Collapse from '@mui/material/Collapse';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    {/* ... 省略图形内容 ... */}
  </Paper>
);

export default function SimpleCollapse() {
  const [checked, setChecked] = React.useState(false);

  return (
    <Box sx={{ height: 300 }}>
      <FormControlLabel
        control={<Switch checked={checked} onChange={(e) => setChecked((prev) => !prev)} />}
        label="Show"
      />
      <div>
        {/* 默认垂直折叠,collapsedSize 设置收起时的最小高度 */}
        <Collapse in={checked}>{icon}</Collapse>
        <Collapse in={checked} collapsedSize={40}>{icon}</Collapse>
      </div>
      <div>
        {/* orientation="horizontal" 切换为水平折叠(基于 width) */}
        <Box sx={{ width: '50%' }}>
          <Collapse orientation="horizontal" in={checked}>{icon}</Collapse>
        </Box>
      </div>
    </Box>
  );
}

Collapse 源码实现 可以印证这些参数的底层机制:其根元素是一个 styled('div'),默认样式为 height: 0 + overflow: 'hidden' + transition: theme.transitions.create('height');当 orientationhorizontal 时,样式切换为 width: 0 并对 width 做过渡。当状态为 entered 时恢复 height/width: 'auto'overflow: 'visible'。此外源码中有一个值得注意的细节:当状态为 exitedinfalsecollapsedSize 恰好为 '0px' 时,会附加 visibility: 'hidden',避免完全收起的元素仍然可交互或被屏幕阅读器感知。

Fade:透明度淡入淡出

Fade 从透明淡入到不透明,是语义最简单的过渡组件。示例 SimpleFade.js 中,用一个 Switch 切换 in 即可触发淡入淡出:

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Paper from '@mui/material/Paper';
import Fade from '@mui/material/Fade';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    {/* 三角形 SVG 内容 */}
  </Paper>
);

export default function SimpleFade() {
  const [checked, setChecked] = React.useState(false);

  const handleChange = () => {
    setChecked((prev) => !prev);
  };

  return (
    <Box sx={{ height: 180 }}>
      <FormControlLabel
        control={<Switch checked={checked} onChange={handleChange} />}
        label="Show"
      />
      <Box sx={{ display: 'flex' }}>
        <Fade in={checked}>{icon}</Fade>
      </Box>
    </Box>
  );
}

Grow:中心放大 + 淡入

Grow 从子元素中心向外放大,同时从透明淡入到不透明。官方示例在第二个场景中演示了两件事:修改 transform-origin 改变缩放原点,以及条件性地应用 timeout prop 来改变入场速度——这意味着同一个元素可以用不同的时长处理"入场"与"离场",例如快速进入、缓慢退出。

Slide:从屏幕边缘滑入

Slide 从屏幕边缘滑入,direction prop 控制过渡起始的屏幕边缘。默认值为 'down'(从上方滑下),可选 'left''right''up' 四个值。

示例 SimpleSlide.js 同时演示了 mountOnEnterunmountOnExit 的用途:

<Box sx={{ height: 180, width: 130, position: 'relative', zIndex: 1 }}>
  <FormControlLabel
    control={<Switch checked={checked} onChange={handleChange} />}
    label="Show"
  />
  <Slide direction="up" in={checked} mountOnEnter unmountOnExit>
    {icon}
  </Slide>
</Box>
  • mountOnEnter 防止子组件在 intrue 之前被挂载,避免处于相对定位的组件从其"屏幕外位置"滚动进入视口;
  • unmountOnExit 在过渡完全移出屏幕后把组件从 DOM 中移除。

Slide 相对容器滑动

Slide 还支持 container prop,其值为一个 DOM 节点的引用(也可以是返回 DOM 节点的函数)。设置后,Slide 将从该 DOM 节点的边缘而非屏幕边缘滑入。示例 SlideFromContainer.js 中用 ref 捕获容器:

const containerRef = React.useRef(null);

// ...
<Box sx={{ p: 2, height: 200, overflow: 'hidden' }} ref={containerRef}>
  <Slide in={checked} container={containerRef.current}>
    {icon}
  </Slide>
</Box>

Slide 源码实现 可以看到几个实现细节:

  1. 离屏位置计算getTranslateValue() 根据 direction 和(可选的)containergetBoundingClientRect() 计算出把节点推出屏幕/容器外的 translateX/translateY 值;进入时先将 transform 重置、读取布局,再触发 reflow,最后把 transform 置为 none 并写入 theme.transitions.create('transform', {...}) 让浏览器执行过渡。
  2. 默认时长与缓动timeout 默认为 { enter: theme.transitions.duration.enteringScreen, exit: theme.transitions.duration.leavingScreen }easing 默认为 { enter: easeOut, exit: sharp }——进入用"减速感"的 easeOut,退出用线性 sharp,这符合 Material 动效规范。
  3. 窗口尺寸自适应:当方向为 left/upinfalse 时,Slide 会监听 resize 事件并重新计算离屏位置,保证窗口缩放后隐藏内容仍然在屏幕外。
  4. SwipeableDrawer 手势兼容:退出过渡时源码会通过 isGestureTranslate() 检测当前 transform 是否为用户手势产生的 translate(x, y),若是则保留手势偏移不覆盖,保证抽屉拖拽退出体验连贯。

Zoom:中心放大

Zoom 从子元素中心向外放大,与 Grow 的差异在于纯缩放(无透明度过渡语义差异上更轻量)。官方示例 SimpleZoom.js 还演示了如何延迟进入过渡(delay enter transition)。

Reduced Motion:尊重系统"减弱动态效果"设置

过渡组件通过主题接入 Reduced Motion 支持。在主题中打开:

const theme = createTheme({
  motion: {
    reducedMotion: 'system',
  },
});

开启后,Material UI 的过渡组件会保留完整的生命周期回调和挂载/卸载行为;当系统开启减弱动态效果时,进入的内容直接出现在最终状态,退出的内容直接消失,不再播放动画,但 onEnteredonExited 等回调照常执行——依赖这些回调做后续状态处理的代码不会受影响。

只有当某个过渡确实需要保留正常动效时,才应使用 disablePrefersReducedMotion

<Fade in disablePrefersReducedMotion>
  <div />
</Fade>

从源码可以确认这套机制的实现:

  • createMotion 显示 theme.motion.reducedMotion默认值是 'never'(即默认不启用),'system' 表示跟随操作系统,另有 'always' 表示始终减弱;
  • useReducedMotion 钩子监听 (prefers-reduced-motion: reduce) 媒体查询。disablePrefersReducedMotiontrue 或模式为 'never' 时不订阅该查询;在 React 18+ 上通过 useSyncExternalStore 读取媒体查询(服务端快照默认按"减弱"处理以保证 SSR 安全),在 React 17 上则回退到 mount 后读取的 useState 实现;
  • 当判定需要减弱动效时,getTransitionTiming() 会把过渡 duration 归零、delay 归为 '0ms',各过渡组件(Fade、Slide、Grow、Zoom、Collapse 等)内部统一消费该返回值。
  • 每个过渡组件内部都接收 Slide 源码 中可见的 reduceMotion 布尔量传入底层 Transition(react-transition-group),使 appear 等时序逻辑也能感知减弱模式。
  • 对应的单元测试见 useReducedMotion.test.tsx

Child requirement:子元素必须转发 style 和 ref

使用过渡组件时,子元素有两条硬性要求(以及一条结构性要求):

  • 转发 style:为了更好地支持服务端渲染,Material UI 会向 Fade、Grow、Zoom、Slide 等过渡组件的子元素注入 style prop。动画要正常工作,style prop 必须被应用到底层 DOM 上;
  • 转发 ref:过渡组件要求第一个子元素把自己的 ref 转发到 DOM 节点上(关于 ref 的注意事项可参考指南页 "Caveat with refs");
  • 单一子元素:过渡组件只接受一个子元素(不允许 React.Fragment)。

官方给出的标准写法是一个 React.forwardRef 组件,把 props(含 style)展开到 div 上,并把 ref 绑定到 div

// The `props` object contains a `style` prop.
// You need to provide it to the `div` element as shown here.
const MyComponent = React.forwardRef(function (props, ref) {
  return (
    <div ref={ref} {...props}>
      Fade
    </div>
  );
});

export default function Main() {
  return (
    <Fade>
      {/* MyComponent must be the only child */}
      <MyComponent />
    </Fade>
  );
}

Slide 源码 也能看到这一契约的另一侧:它通过 cloneElement 把 fork 过的 ref 与合并后的 style 注入子元素,并在 exited 状态下自动叠加 visibility: 'hidden'

TransitionGroup:批量增删元素的过渡

如果需要"组件挂载/卸载时自动触发动画",可以使用 react-transition-group 的 TransitionGroup 组件。组件被添加或移除时,TransitionGroup 会自动切换每个子元素的 in prop,无需手动管理。

示例 TransitionGroupExample.js 演示了一个"水果篮子"列表:新增项从顶部 Collapse 展开,删除项折叠消失,in 全程由 TransitionGroup 自动驱动:

import { TransitionGroup } from 'react-transition-group';
import Collapse from '@mui/material/Collapse';

function renderItem({ item, handleRemoveFruit }) {
  return (
    <ListItem
      secondaryAction={
        <IconButton
          edge="end"
          aria-label="delete"
          title="Delete"
          onClick={() => handleRemoveFruit(item)}
        >
          <DeleteIcon />
        </IconButton>
      }
    >
      <ListItemText primary={item} />
    </ListItem>
  );
}

export default function TransitionGroupExample() {
  const [fruitsInBasket, setFruitsInBasket] = React.useState(FRUITS.slice(0, 3));

  const handleAddFruit = () => {
    const nextHiddenItem = FRUITS.find((i) => !fruitsInBasket.includes(i));
    if (nextHiddenItem) {
      setFruitsInBasket((prev) => [nextHiddenItem, ...prev]);
    }
  };

  const handleRemoveFruit = (item) => {
    setFruitsInBasket((prev) => [...prev.filter((i) => i !== item)]);
  };

  return (
    <div>
      <Button variant="contained" disabled={fruitsInBasket.length >= FRUITS.length} onClick={handleAddFruit}>
        Add fruit to basket
      </Button>
      <List sx={{ mt: 1 }}>
        <TransitionGroup>
          {fruitsInBasket.map((item) => (
            <Collapse key={item}>{renderItem({ item, handleRemoveFruit })}</Collapse>
          ))}
        </TransitionGroup>
      </List>
    </div>
  );
}

注意:TransitionGroup 的每个直接子元素需要唯一 key(示例中即 item),否则增删动画无法正确匹配进出场元素。

Transition slots:自定义组件内部过渡

Material UI 的许多组件(Modal、Dialog、Popper、Snackbar、Tooltip 等)内部使用这些过渡。通过 slots.transitionslotProps.transition 可以替换它们的默认过渡——既可以用上述五个组件之一,也可以用自定义实现。自定义过渡必须满足:

  • 接受 in prop(对应打开/关闭状态);
  • 在进入过渡开始时调用 onEnter 回调 prop;
  • 在退出过渡完成时调用 onExited 回调 prop。

这两个回调用于在"关闭且过渡完全结束"时卸载子内容。要创建符合该协议的过渡,可参考 react-transition-group 的 Transition 文档;各消费组件(Modal、Dialog、Popper、Snackbar、Tooltip)的文档中也都有各自的 "Transitions" 章节说明其 slot 用法。

Performance & SEO:unmountOnExit 权衡

过渡组件的内容默认始终挂载,即使 in={false}。这个默认行为是为了服务端渲染与 SEO 考虑:内容在 DOM 中,爬虫与无障碍工具都能读取到。

如果过渡内部渲染了昂贵的组件树,可以考虑用 unmountOnExit 反转这个默认行为,在关闭后卸载内容:

<Fade in={false} unmountOnExit />

如同所有性能优化一样,这不是银弹:应先定位真实的性能瓶颈,再应用这类优化策略。

小结

  • Material UI 的五个过渡组件(Collapse、Fade、Grow、Slide、Zoom)共享同一套 props 协议(inmountOnEnter/unmountOnExittimeouteasing、六个生命周期回调等),类型定义集中在 packages/mui-material/src/transitions/types.ts,工具函数(getTransitionPropsreflownormalizedTransitionCallback 等)在 packages/mui-material/src/transitions/utils.ts
  • theme.motion.reducedMotion 默认 'never',设置为 'system' 后由 useReducedMotion 钩子监听 prefers-reduced-motion 媒体查询并将动画时长归零,同时保持回调协议不变;
  • 子元素必须转发 styleref、且只能有一个,这是所有过渡组件的硬性契约;
  • 通过 slots.transition / slotProps.transition 可替换 Modal、Dialog、Snackbar 等组件的默认过渡;
  • 需要列表级进出场动画时,用 TransitionGroup 自动驱动 in prop,省去手动状态管理。
登录后查看全文
热门项目推荐
相关项目推荐