Material UI 过渡动画(Transitions)完整指南:Collapse、Fade、Grow、Slide、Zoom 与 Reduced Motion 深度解析
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:控制过渡是否处于"进入"状态(对应打开/关闭);mountOnEnter:in为true之前子元素不挂载;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');当 orientation 为 horizontal 时,样式切换为 width: 0 并对 width 做过渡。当状态为 entered 时恢复 height/width: 'auto' 与 overflow: 'visible'。此外源码中有一个值得注意的细节:当状态为 exited、in 为 false 且 collapsedSize 恰好为 '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 同时演示了 mountOnEnter 与 unmountOnExit 的用途:
<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防止子组件在in为true之前被挂载,避免处于相对定位的组件从其"屏幕外位置"滚动进入视口;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 源码实现 可以看到几个实现细节:
- 离屏位置计算:
getTranslateValue()根据direction和(可选的)container的getBoundingClientRect()计算出把节点推出屏幕/容器外的translateX/translateY值;进入时先将transform重置、读取布局,再触发 reflow,最后把transform置为none并写入theme.transitions.create('transform', {...})让浏览器执行过渡。 - 默认时长与缓动:
timeout默认为{ enter: theme.transitions.duration.enteringScreen, exit: theme.transitions.duration.leavingScreen },easing默认为{ enter: easeOut, exit: sharp }——进入用"减速感"的 easeOut,退出用线性 sharp,这符合 Material 动效规范。 - 窗口尺寸自适应:当方向为
left/up且in为false时,Slide 会监听resize事件并重新计算离屏位置,保证窗口缩放后隐藏内容仍然在屏幕外。 - 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 的过渡组件会保留完整的生命周期回调和挂载/卸载行为;当系统开启减弱动态效果时,进入的内容直接出现在最终状态,退出的内容直接消失,不再播放动画,但 onEntered、onExited 等回调照常执行——依赖这些回调做后续状态处理的代码不会受影响。
只有当某个过渡确实需要保留正常动效时,才应使用 disablePrefersReducedMotion:
<Fade in disablePrefersReducedMotion>
<div />
</Fade>
从源码可以确认这套机制的实现:
- createMotion 显示
theme.motion.reducedMotion的默认值是'never'(即默认不启用),'system'表示跟随操作系统,另有'always'表示始终减弱; - useReducedMotion 钩子监听
(prefers-reduced-motion: reduce)媒体查询。disablePrefersReducedMotion为true或模式为'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 等过渡组件的子元素注入
styleprop。动画要正常工作,styleprop 必须被应用到底层 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.transition 和 slotProps.transition 可以替换它们的默认过渡——既可以用上述五个组件之一,也可以用自定义实现。自定义过渡必须满足:
- 接受
inprop(对应打开/关闭状态); - 在进入过渡开始时调用
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 协议(
in、mountOnEnter/unmountOnExit、timeout、easing、六个生命周期回调等),类型定义集中在 packages/mui-material/src/transitions/types.ts,工具函数(getTransitionProps、reflow、normalizedTransitionCallback等)在 packages/mui-material/src/transitions/utils.ts; theme.motion.reducedMotion默认'never',设置为'system'后由useReducedMotion钩子监听prefers-reduced-motion媒体查询并将动画时长归零,同时保持回调协议不变;- 子元素必须转发
style与ref、且只能有一个,这是所有过渡组件的硬性契约; - 通过
slots.transition/slotProps.transition可替换 Modal、Dialog、Snackbar 等组件的默认过渡; - 需要列表级进出场动画时,用
TransitionGroup自动驱动inprop,省去手动状态管理。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00