Motion 连续布局过渡:让内容与周围元素一起移动
点击“高级选项”,一组设置缓缓显现,下面的按钮随之让出空间;再次收起,内容淡出,空隙也逐渐收回。用户能一直认出刚才那颗按钮在哪里。
这类变化很常见,实现时却容易只完成一半:弹窗外框已经平滑变高,内部新内容仍在一帧内全部出现,后面的元素先跳到终点,再被一个正在变大的外框包住。
FusionKit 的字幕工作台和翻译资料页面就经历过这次修正。第一轮处理了弹窗尺寸,实际体验仍暴露出内部折叠、内容切换和下方元素的跳动。问题出在动画作用的位置:外框动起来,不代表内部文档流已经连续。
交互演示: 打开「连续布局过渡」对比实验,观察只动外框与内容、占位共同过渡的区别,再尝试快速反向开合和嵌套展开。
给效果起一个便于复用的名字
我把这组要求收录为 Motion Recipes 的 「连续布局过渡」,英文工作名是 Flow-aware content transitions。这是这个案例集的约定名称,方便描述和检索,并不是 Motion 官方 API,也不代表行业已有一个同名标准。
它约定了三个同时成立的结果:
这比“加点动画”具体,也比直接指定“所有节点加 layout”更接近需求。折叠面板、表单的条件字段、异步提示和不同高度的内容页签,都可能需要这份约定,但可以选择不同的实现。
先找出真正改变文档流的节点
假设一个弹窗里依次是标题、高级设置和提交按钮。只测量整个弹窗,再给弹窗的 height 加动画,并不能阻止高级设置瞬间改变自然布局。提交按钮在弹窗内部的位置已经跳了。
应该把尺寸动画放在高级设置实际占位的那一层:
自然内层告诉我们内容需要多高,外层决定当前占多少空间。两者不能是同一个节点,否则测到的可能是被动画限制后的高度。
关于两个自然高度之间的转换,可以先看 《Motion 中 auto 到 auto 的平滑过渡》。这次多了一条关键要求:测量容器的边界要贴近变化来源,不能只包在整棵界面树的最外面。
一个可运行的局部折叠示例
下面的组件针对普通文档流中的单层折叠内容。它始终保留子树,适合需要保留输入草稿的设置区。调用方提供控制状态和关联到触发按钮的 id。示例使用 React 19 的布尔 inert 属性;Next.js App Router 中保留 "use client" 边界,浏览器订阅只在客户端执行。
这里有几个明确的边界:
null代表尚未测量,真实的零高度仍然是有效结果。初次展开状态用自然高度渲染,initial={false}避免额外从零播放一次。- 外层不带 padding 和 border;测量值与它的内容高度对应。需要随内容收回的顶部间距放在自然内层。
- 内层只改变透明度,没有缩放文字。普通兄弟元素由真实高度变化推动,因此示例中的提交按钮不需要
layout。 - 关闭时立即禁用隐藏区域的交互,却保留 DOM 完成视觉退出。如果程序主动关闭一个仍持有焦点的区域,应先把焦点交还触发器。
减少动态效果的偏好通过 useSyncExternalStore 实时订阅。还有一个容易遗漏的细节:动画中途切换偏好时,目标高度可能没有改变,只把 transition 改成零时长,不一定会停止已经运行的 spring。示例在此时显式提交 [target, target],让零时长关键帧接管旧动画;透明度也同样处理。首次服务端渲染使用稳定快照,挂载后再读取真实偏好。
这是局部示例,没有处理多层嵌套的动画归属,也不承担 Presence 阶段切换。完整组合可在 Motion Recipes 案例集中查找「连续布局过渡」案例与模板。
layout 仍然有用,区别在于谁负责连续性
Motion 的 layout 动画通过布局测量和 transform 投影衔接前后状态,适合列表重排、卡片位置变化和共享布局。它不是“不能用”,也不是所有显隐问题的一键修复。
AnimatePresence mode="popLayout" 会把退出项从文档流中移开,让其他元素开始重排;它本身并不保证所有受影响元素都平滑移动。官方 AnimatePresence 文档也展示了它与布局动画的配合。
如果已经用真实高度推动普通文档流,再无差别给所有后代加布局投影,反而增加了多套动画协调的负担。应该先确定一处空间变化由谁驱动,再补足离散重排的部分。
完整退出,包括草稿和最后一段空隙
下面的写法会抢先移除动画要展示的内容:
关闭后,外框还有机会收缩,表单却已经消失。需要保留草稿时,让表单常驻;内容很重时,可以首次展开才挂载,之后保留到所属弹窗关闭。可丢弃的阶段则交给 Presence 管理卸载,不能凭一个固定 setTimeout 猜测动画结束。
间距也是占位。比如父级使用 gap: 12px,退出容器即使已经变成零高度,只要还在列表中,gap 可能仍在;最后卸载节点时,剩余间距才突然消失。
一种容易推理的做法是保留稳定分组,把只属于可变内容的间距放进被测量内层的 padding。确实需要删除整个分组时,要让间距与退出过程一起交接。不要只盯着 height: 0,还要观察删除前后的最后一帧。
嵌套展开时,外层不要再追一次弹簧
折叠面板套在弹窗中,内层的真实高度已经每帧变化。外层 ResizeObserver 自然会读到一串中间值。如果每次观察结果都成为新的 spring 目标,外层就一直追赶内层,收尾会显得拖沓。
可以约定两种状态:
- 内层正在驱动连续占位变化,稳定展开的祖先直接跟随观测结果,不再次缓动。
- 祖先自己收到打开、关闭或反向操作,祖先的显式操作取得优先权,不能因为发现子级在动就瞬间跳到零高度。
实际实现可以用上下文或局部标记传递“正在改变占位”,并排除已经退出文档流的视觉副本。标记应跟随动画完成与取消清理,不能靠写死的时长结束。
同样,避免让 transition-all、CSS 高度动画和 Motion 同时驱动同一节点的 height。CSS 可以继续处理颜色和 hover;这里要消除的是同一属性的多个控制者。
与 Radix / shadcn 组合时保留清晰边界
Radix 提供状态、键盘行为和生命周期能力。它的 Collapsible 文档同时提供 forceMount 和用于尺寸动画的 CSS 变量,简单折叠完全可以使用它提供的方式。
需要更复杂的测量式过渡时,应检查项目实际安装版本的测量行为,不要假设原语内部测量与外部动画永远互不影响;内部实现细节也不是稳定的公开 API。FusionKit 这次将结构分成三层:Radix Content 保留语义与挂载控制,内部独立的原生 motion.div 管高度,再里面的自然节点提供测量值和透明度。
forceMount 只解决保留挂载的问题,不会替你补齐淡出、占位变化和隐藏交互。采用常驻方案时,需要一并检查 inert、焦点和可访问性树;采用真实卸载时,则要保留可靠的退出完成信号。
如何确认实现完成了
验收时同时观察内容、局部占位和后续元素,单看弹窗外框或最终截图不够。
- 慢放展开与收起:内容透明度和真实高度都出现中间值,下方按钮的位置连续变化。
- 动画中途反向操作:从当前状态返回,没有突然重置到端点。
- 检查最后一帧:退出内容、gap、padding 不留下突然消失的空白。
- 嵌套展开后立刻关闭外层:关闭仍有完整过程,外框没有拖尾追赶。
- 输入草稿后反复开合:原值保留,收起区域不可被键盘进入。
- 调整宽度、加载异步内容,再开启减少动态效果偏好:最终尺寸正确,交互保持可用。
记录后续元素位置时,最好使用它相对稳定内容区的坐标,避免居中弹窗整体移动掩盖内部跳变。真实 width / height 动画会产生布局计算,应限制在合适的局部范围,并在目标设备上检查;不要把这一模式机械套到高频日志、逐字输出或超长列表上。
以后可以这样告诉 AI
这份约定把几种已有技巧串成了可观察的结果:Presence 负责内容什么时候离开,测量负责知道需要多少空间,局部高度动画负责连续改变占位,布局投影按需衔接离散重排。以后说“这里也做连续布局过渡”,就有了共同的实现边界与验收标准。