从按钮到面板,从卡片到浮层:Motion 容器变形过渡的两种实现策略
有一类过渡效果,看到的人往往说不出它叫什么,但都能立刻感觉到"高级":
- 文章列表页右上角有一个小小的"筛选"按钮,点击后它没有弹出一个新面板,而是自己原地生长,边框、圆角、阴影都被继承下来,几百毫秒后它已经是一个完整的筛选面板——搜索框、分类、标签都在里面。
- 旅行手册的观景清单里,点击一张景点卡片,卡片从网格里"起飞",一边移动一边长大,落在视口正中央变成一个详情浮层;背后的页面暗下来、蒙上一层模糊,卡片上的序号、徽章和标题在飞行过程中一直"跟着",落地后详情内容才逐层浮现。
这两个效果看起来差别不小——一个在原地展开,一个飞到屏幕中央——但它们是同一种过渡模式的两个变体。这篇文章先给它正名,再把两种实现策略拆开讲清楚,最后沉淀出一份无论哪种变体都适用的工程清单。
一、这种效果叫什么
它的通用名称是 Container Transform(容器变形过渡),出自 Material Design 的 motion 体系,是官方定义的四种过渡模式之一。它的定义很朴素:
当两个 UI 状态之间存在一个"容器"时,让这个容器的几何变化承担过渡,从而在两个状态之间建立可见的连接。
不同技术社区对同类效果有不同的叫法,指向的核心思想是一致的:
而在实现层面,这类过渡几乎都建立在 FLIP(First, Last, Invert, Play)之上:先记录起点几何,再确定终点几何,然后在两者之间播放动画。Motion 的 layout / layoutId 动画就是 FLIP 的自动化封装。
理解这一点后,问题就从"这个效果怎么做"变成了三个更具体的问题。
二、心智模型:三个必须回答的问题
容器变形过渡的关键心智转变是:不是"关闭 A、打开 B",而是"同一块表面换了角色"。
用户的眼睛会跟随那块连续变化的表面,只要它的几何变化是连续的,大脑就会把按钮和面板、卡片和浮层理解为同一个东西。这就是所谓的空间连续性(spatial continuity)。
要维持这种连续性,任何一个实现都必须回答三个问题:
- 容器几何是骨架。做不好它,一切免谈。
- 内容编排决定质感。容器变大十倍,里面的文字不能跟着拉伸十倍。
- 环境配合决定完成度。没有遮罩和焦点管理的居中浮层,只是一个会动的 div。
下面按照这三个问题,分别拆解两种实现策略。
三、先排除一个诱人的弯路:给整个容器套 layoutId
熟悉 Motion 的读者第一反应可能是:卡片和浮层各渲染一份,两边套上同一个 layoutId,Motion 自动 FLIP,一行配置搞定。
这个方案对小元素(徽章、标题、指示器)非常好用,但对整个容器通常不是最优解,原因有三:
- 投影失真。
layoutId的 FLIP 投影基于transform: scale()。当容器从 300×176 变到 640×520,过渡中的每一帧都是对某一端快照的缩放。Motion 会对直接子元素做尺寸矫正(scale correction),但对深层嵌套的复杂内容——多行文字、网格、边框——矫正并不完美,过渡中会出现文字拉伸、圆角变形。 - 终点几何需要显式控制权。 居中浮层的终点矩形依赖视口尺寸、安全边距、内容实际高度做计算和 clamp,还要在窗口 resize 时更新。这些逻辑塞不进"自动 FLIP"的黑盒里。
- 组合时序脆弱。 容器级
layoutId往往还要和AnimatePresence、portal、滚动容器组合,任何一环的挂载时机不对,就会出现闪跳或者动画丢失,而且难以调试。
所以两个真实实现共同的选择是:容器几何用数值驱动(显式地 animate 宽高或矩形),layoutId 只用于容器内部的小元素迁移。 数值驱动意味着每帧触发布局重排,这是一个明确的取舍——换来的是文字自然 reflow、圆角边框全程稳定,对单个面板/浮层来说成本完全可接受。
四、策略 A:原地扩展——筛选按钮长成面板
适用拓扑: 展开后的面板仍然锚定在触发器的位置(共享同一个角),类似一个"会变形的 Popover"。
4.1 结构:一个容器,两层内容
这个策略里根本没有"两个元素"——触发器和面板共用同一个容器节点,容器的宽高在两组测量值之间做 spring 动画:
几个不显眼但重要的决定:
- 容器
absolute锚定在top-0 right-0,从右上角向左下方生长,触发器视觉位置全程不动。 - 触发器内容用
AnimatePresence真实卸载,因为收起态需要它可聚焦、可点击;面板内容则常驻挂载只变透明度,避免每次展开都重新构建整棵子树(面板里可能有搜索框、标签列表这类有状态的内容)。 - 测量与展示分离:容器上放一个
invisible的触发器内容副本专门用于测量宽度(筛选计数变化时宽度会变),面板内容自身则挂ResizeObserver——面板内部展开"更多标签"时,容器高度会跟着新测量值平滑生长。
4.2 实现模板
高亮部分是这个策略最容易被忽略的一环:isInteractive 在打开和关闭时都先置为 false,等容器 spring 播完(onAnimationComplete)才恢复。配合面板内容上的 inert,可以保证:
- 过渡进行中,面板里的按钮不会被点到(半透明状态点击是很差的体验);
- 键盘 Tab 不会聚焦到还没完全出现的控件上。
4.3 时序设计:容器先行,内容跟随
打开时的时间线是错峰的:
t = 0:容器开始 spring(约 400ms);t = 80ms:面板内容开始淡入(220ms)——此时容器已经撑开了大半,内容"落进"一个已经基本成形的表面;- 触发器内容在
t = 0立刻开始淡出(100ms)。
关闭时反过来:面板内容立刻开始淡出(280ms,不延迟),触发器内容等容器基本缩回后(delay 240ms)再出现。
这个"退出快、进入缓,且带延迟"的编排是所有内容切换动效的通用规律:旧内容不要恋战,新内容不要抢跑。
五、策略 B:矩形迁移——卡片飞向视口中央
适用拓扑: 终点不在触发器附近,而在视口中央(或任何指定位置),本质是"卡片 → 模态浮层"。这时单容器方案不够了,因为起点和终点分别处于文档流和视口两个坐标世界。
5.1 核心决策:占位与视觉分离
最关键的架构决定是:留在文档流里的元素和被动画的元素不是同一个。
真实的 button 一直待在网格里,只是 opacity-0。它的作用一点都不小:
- 布局占位:卡片"起飞"后网格不塌陷,其他卡片纹丝不动;
- 交互与无障碍:点击目标、
aria-expanded、aria-controls都在它身上,屏幕阅读器看到的是一个规范的展开控件; - 焦点还原的锚点:关闭浮层后焦点回到它身上。
而用户看到的"卡片",从头到尾都是那个 absolute 定位的视觉克隆。平时它精确覆盖在占位按钮上(通过测量得到的矩形定位),点击后它的矩形被换成"视口中央的浮层矩形",spring 自然把它送过去。
5.2 坐标系:把所有矩形换算到同一个原点
视觉克隆是相对列表容器 absolute 定位的,但起点矩形来自 getBoundingClientRect()(视口坐标),终点矩形也是按视口计算的。所以所有矩形都要减去列表容器自身的视口位置,换算成列表内坐标:
注意 readCenteredPanelRect 接收 contentHeight:详情内容挂了 ResizeObserver,测出实际高度后回传,终点矩形随之更新,spring 会平滑地跟到新目标。于是浮层高度是内容感知的——短内容浮层就矮一点,长内容撑到视口上限。
测量的维护也有讲究:
- 列表容器和每个卡片的矩形通过
ResizeObserver+scroll/resize监听维护(滚动不改变尺寸,ResizeObserver不会触发,必须单独监听滚动); - 浏览器可能在点击按钮聚焦时执行一次微小的自动滚动,赶在任何监听回调之前。对策有两个:给按钮加
onPointerDown={(e) => e.preventDefault()}阻止点击聚焦,以及在渲染期直接读一次 live rect 兜底。
5.3 相位状态机:boolean 不够用
一个 isOpen 布尔值撑不起这套编排,需要显式的四相位状态机:
每个相位的职责不同:
- opening:容器从 origin rect 飞向 target rect;卡片的非共享内容淡出;详情的辅助内容延迟淡入;遮罩延迟 120ms 淡入。
- open:详情可滚动、可交互;页面滚动被锁定。
- closing:关闭那一刻重新测量触发器矩形作为返回目标(窗口可能已经 resize,卡片位置可能已经变了),容器飞回去;详情内容立刻淡出;遮罩用更短的时长(180ms)退场。
- closed:卸载遮罩与详情,焦点还原到触发器(
focus({ preventScroll: true }),避免还原焦点引发页面跳动)。
5.4 列表渲染:三层结构落地
视觉层的 animate={visualRect} 是整个策略的发动机:非激活时矩形等于占位按钮的测量矩形(克隆精确覆盖原位),激活时矩形换成居中浮层矩形,关闭时又换回重测后的按钮矩形。同一个节点、同一条 spring,起飞和降落只是目标值的切换。
5.5 内容编排:谁飞行,谁淡场
容器在飞,里面的内容分成三类,各司其职:
- 共享元素(序号、类型徽章、标题):这些元素在卡片和详情里都存在,用
layoutId让它们在两个视觉之间连续迁移——这正是layoutId最擅长的粒度:
layoutCrossfade={false} 很重要:默认的交叉淡化会在过渡中同时显示两份文字,产生重影;关掉后 Motion 只保留一份投影,标题看起来是"一个字都没变,只是移动并放大了"。
关闭时还有一个精细处理:详情侧把 layoutId 注销(改渲染普通元素并 invisible 占位),同名 layoutId 的接力棒交回卡片侧的元素,Motion 自动把它们从详情位置迁回卡片位置——回程动画一行都不用写。
-
卡片的非共享内容(描述、标签、图标):打开时淡出(180–200ms),把舞台让给飞行的共享元素;关闭(closing)时恢复不透明,等着克隆落地。
-
详情的辅助内容(数据网格、操作按钮):
opacity: 0, y: 5起步,等容器基本就位后淡入;关闭时立刻淡出。这就是"卫星内容错峰进退"——共享元素负责连续性,其余内容只做轻量的淡入淡出,不抢戏。
5.6 环境层:遮罩、阴影与层级
遮罩通过 portal 渲染到 document.body,避免被列表的任何祖先 overflow 或层叠上下文裁剪:
这里借了一个 CSS 变量的道:backdrop-filter 直接交给 Motion 动画在部分浏览器上表现不稳定,改为动画自定义属性、再让 backdropFilter 引用它,就能平滑过渡。
阴影没有用容器自己的 box-shadow,而是单独一层 bg-black/20 blur-xl 的柔影 div,跟着同一个矩形飞行。原因是大面积 box-shadow 在尺寸动画中每帧重绘的开销更高,而且独立层可以有自己的透明度时间线(比容器晚 40ms 出现,closing 时提前消失)。
层级用明确的 z-index 阶梯管理,portal 遮罩必须垫在所有飞行元素之下:
六、两种策略如何选
判断标准可以压缩成一句话:展开后的表面还"属于"触发器附近吗? 属于,就用策略 A,让同一个节点变形;不属于(要成为全局模态),就用策略 B,让克隆飞行。
两种策略也可以看作同一条谱线的两端——策略 A 其实就是"起点矩形和终点矩形共享同一个锚点"的退化情形,所以它才能省掉坐标换算和占位分离。
七、无论哪种策略都要做的事
最后这份清单与策略无关,是这类过渡从"能动"到"完成"的分界线:
- 过渡期交互锁定。 用
inert+pointer-events确保半路上的内容不可点、不可聚焦,动画播完再解锁。 - 焦点管理闭环。 打开后焦点移入浮层(优先关闭按钮),模态浮层内做 focus trap,关闭后焦点还原到触发器且
preventScroll。 - 键盘与指针的关闭路径。 Escape 关闭、点击遮罩/外部关闭,两条路都要通。
- 滚动锁定。 模态浮层打开时锁定背景滚动(拦截
wheel/touchmove,放行浮层内部)。 useReducedMotion。 用户开启"减少动态效果"时,所有时长归零、直接切换状态,语义流程(焦点、遮罩、关闭)保持不变。- 退出永远快于进入。 遮罩进 320ms / 退 180ms,内容进 220ms / 退 140ms 左右——关闭是用户明确的意图,不要让动画拖住它。
isolate隔离层叠上下文。 容器和定位锚上加isolate,避免内部 z-index 阶梯泄漏出去和页面其他元素打架。
参数上,两个实现殊途同归,可以作为这类过渡的基准值:
容器用 bounce: 0 不是保守,而是这类过渡的性格使然:表面在"变形"而不是"弹跳",任何过冲都会让边框和圆角在终点附近抖一下,破坏"这就是同一个东西"的错觉。
八、结语
回头看,容器变形过渡的全部复杂度都来自一个朴素的目标:让用户相信按钮和面板、卡片和浮层是同一块表面。
为了维护这个错觉,我们把问题拆成三层——几何用数值驱动的矩形动画保证连续,内容用"共享元素迁移 + 卫星内容错峰"保证不失真,环境用遮罩、阴影、焦点和滚动管理保证完成度。layoutId 没有缺席,只是回到了它最合适的岗位:搬运容器里那些小小的、需要连续性的主角。
下次再看到"点一下,它就长成了另一个东西"的效果,你不仅知道它叫 Container Transform,也知道该从哪三个问题开始动手了。