内容高度变化不再跳动:Motion 中 auto 到 auto 的平滑过渡
有一类界面变化很轻,却很容易暴露实现上的粗糙。
比如设置卡片里多出一段说明,表单下方出现校验信息,异步数据返回后补上详情,或者一行文字因为容器变窄而换成两行。内容本身没有切换页面,也没有打开新的弹窗,只是比刚才高了一些,下面的按钮和卡片却在一帧之内被突然推开。
这不是功能错误,但用户会感到界面“跳了一下”。
在 《Motion for React 实战模式手册》 中,我写过用 height: 0 和 height: "auto" 实现菜单展开。那种写法适合从“收起”到“展开”的状态变化。但当一个已经展开的容器从一个自然高度变到另一个自然高度时,问题会悄悄变成另一种:动画的起点和终点都是 auto。
这篇就从这个小问题出发,看看怎样让内容高度变化自然衔接,同时守住首帧、尺寸测量、性能和可访问性的边界。
本文的起点来自 animations.dev 的 Animating height 章节。在基础思路之上,本文进一步补充方案选择、原生
ResizeObserver实现、box sizing、异步资源与高频更新等工程细节。
一、先分清四种“高度变化”
看到高度跳动时,第一反应不应该是立刻加 ResizeObserver。不同问题适合不同的工具,测量只应该在真的需要时出现。
第一种情况很直接:
Motion 可以在确定值和 auto 之间工作,是因为它能在动画开始时解析一次目标尺寸。
但下面这个写法没有提供两个可补间的端点:
状态变化前,height 是 auto;状态变化后,它仍然是 auto。浏览器知道每一帧应该怎样排版,却没有一个“旧高度 148px 到新高度 236px”的动画任务。内容会先完成重排,外层高度也就直接跳到新结果。
layout 为什么有时已经够用
给容器加上 layout,Motion 可以读取变化前后的布局框,再通过 transform 补偿制造连续过渡。对于普通卡片和列表重排,这通常是最轻的方案:
如果这个效果已经满足设计,就停在这里。
只有当你需要动画层真正拥有一个不断变化的 CSS height,例如需要在过渡期间精确裁剪内容、稳定推动相邻布局,或者内容高度会被图片、字体和内部组件继续改变时,才值得进入测量方案。
二、核心结构:内层自然排版,外层负责动画
auto -> auto 动画真正重要的不是某个 Hook,而是两层职责的分离:
对应的 DOM 骨架非常小:
- 内层不设置显式高度,始终保持自然排版,负责提供真实尺寸。
- 外层接收测量得到的像素值,负责高度补间和裁剪。
- Observer只报告目标变化,动画过程交给 Motion。
为什么不能测量和动画同一个元素
下面这种结构看起来更省一个节点,但它会形成测量反馈:
第一次动画后,这个元素已经被显式 height 约束。下一次测量时,Observer 读到的可能是被约束后的外层高度,甚至是动画过程中的中间值,而不是内容真正需要的自然高度。
多保留一个普通 div,让“被测量者”和“被控制者”分开,反而是更稳定、更容易推理的结构。
三、不增加依赖:用原生 ResizeObserver 封装高度测量
这个博客项目已经使用 motion,但没有依赖 react-use-measure。对于一个只需要高度的组件,原生 ResizeObserver 已经足够。
先把尺寸监听封装成一个小 Hook:
这里有几个容易被忽略的选择:
- 高度状态使用
number | null,而不是直接从0开始。null表示“尚未测量”,真实内容即使是0px也不会与初始状态混淆。 getBoundingClientRect().height会得到包含 padding 和 border 的实际边界尺寸,适合直接作为动画目标。- 小于
0.5px的变化会被忽略,避免缩放和子像素舍入造成无意义更新。 - cleanup 中必须
disconnect(),否则 React Strict Mode 重挂载或组件卸载后仍可能保留订阅。
ResizeObserver 比在业务状态变化时手动读取一次 scrollHeight 更完整。它还能捕获:
- 容器变窄后文字重新换行
- Web Font 加载后行高变化
- 图片和图表完成加载
- 子组件内部状态改变
- 异步内容分批到达
我们关心的是“元素尺寸真的变了”,而不是猜测“哪一个 state 可能让它变”。
四、把测量结果交给 Motion
接下来将 Hook 放进一个可复用的 AnimatedAutoHeight 组件:
这段实现里,真正关键的是四个细节。
1. 首次测量前保持 auto
Observer 要等元素挂载后才能返回尺寸。如果首次渲染直接使用 0,服务端内容会先塌陷,再从零展开一次,造成额外的 layout shift。
首次渲染保持自然高度。第一次测量只是把当前自然高度固定成同等的像素值,不会产生可见跳变;后续尺寸变化才会在两个数字之间补间。
2. 不播放无意义的初始动画
这个组件的职责是衔接内容变化,不是每次挂载都从某个虚构高度入场。关闭初始动画能让它更安静,也更适合 SSR 页面。
3. 高度动画不要带明显回弹
高度发生过冲时,外框会短暂大于内容需要的尺寸,很像一次布局错误。因此这里使用 bounce: 0,让弹簧只保留自然的减速感。
4. reduced motion 下立即完成
对减少动态效果的用户,容器仍然需要得到正确高度,但不需要经过补间过程。将 duration 设为 0,功能和布局都可以保持正常。
五、一个完整使用场景
下面用设置卡片模拟最常见的内容增删:
这类动画最好保持克制。内容变化已经是界面的主事件,高度只需要把布局关系交代清楚,不必同时叠加大幅位移、缩放和明显弹跳。
如果额外内容本身也需要轻微淡入,可以给新增段落加很小的 opacity 和 y: 4,但不要让内容动画与外框高度争抢注意力。
六、项目已有 react-use-measure 时,可以更短
如果项目本来就在使用 react-use-measure,同样的结构可以压缩很多:
useMeasure 的初始高度通常是 0,所以这里在大于零之前保留 auto。如果组件的合法内容确实可能是 0px,前面的原生版本更容易用 null 清楚地区分“未测量”和“测量结果为零”。
不要为了少写十几行代码就在每个项目里立即增加依赖。已经存在就复用;只用一次时,原生 Observer 往往更透明。
七、真正容易踩坑的地方
1. padding 应该放在测量层
外层最好只负责 height 和裁剪。影响整体高度的 padding 放在被测量内层,目标值才能完整包含上下间距。
被测量根节点也尽量不要使用可能逸出边界的垂直 margin。优先用 padding、gap 或 space-y-*。
2. 注意 border-box 少掉的 1–2px
Tailwind Preflight 默认使用 box-sizing: border-box。如果外层带有 border,而你把内层测得的高度直接写给外层,边框可能会占掉 content box 的一小部分,导致内容在底部被裁掉一两个像素。
这里使用了:
另一种做法是把 border 一并放到被测量内层。不要在高度上手工写死 + 2,因为边框宽度和主题样式可能变化。
3. overflow 会裁掉什么
高度收缩时必须裁剪溢出,否则文字会提前穿出外框。但 overflow-hidden 也可能裁掉内容的 focus ring、阴影或浮层。
如果内部有键盘可聚焦控件,把 focus ring 设计在控件内部,或者把阴影和装饰外壳放到动画裁剪层之外。不要为了保留阴影,直接移除高度动画所必需的裁剪。
4. 不要让 CSS 和 Motion 同时控制 height
外层如果同时存在 transition-all、transition-[height] 或 height keyframes,浏览器和 Motion 会对同一属性重复插值,结果通常是迟滞、抖动或时长失控。
颜色、边框和 hover 可以交给 CSS;高度只交给 Motion。
5. 异步内容不等于只测一次
一次 scrollHeight 读取很难覆盖图片加载、字体替换和响应式换行。Observer 的价值就在于它持续观察“最终布局事实”,不必把测量逻辑绑在每一个业务 state 上。
6. 流式内容不适合一直追赶
逐 token 输出、实时日志、拖拽 resize 和每帧变化的图表,会不断产生新的目标高度。如果每一次变化都启动 spring,容器会一直追赶目标,滚动位置也可能持续漂移。
这类高频阶段应该直接使用 auto 或瞬时高度,等内容稳定后再恢复动画;很多时候,完全不做高度动画反而更好。
八、参数应该跟着变化距离走
动态高度动画通常不是视觉主角。它更像是布局之间的一次交接,所以参数应当偏稳:
变化只有十几像素时,太长的 spring 会让界面显得黏滞;变化超过几百像素时,又不应该为了完整展示运动而让用户等待很久。实际体验中,0.22s - 0.4s 已经能覆盖大多数卡片、表单和说明面板。
高度动画会触发布局计算,无法像 transform 和 opacity 那样完全交给合成线程。把它限制在低频、局部、用户可感知的变化上,通常不会成为性能问题;把它用在持续更新的大内容区,就会很快失去这种从容。
九、完成后的验收清单
实现之后,我会从下面几类变化逐项检查:
布局
- 内容变高时,外框连续展开,没有一帧跳到目标高度
- 内容变矮时,外框连续收回,文字不会穿出边界
- 第一次渲染保持自然高度,不会从
0px额外入场 - 下方按钮、卡片和页面内容连续移动
尺寸来源
- 调整容器宽度导致文字换行后,高度会重新更新
- 图片、Web Font 或异步组件加载后,能得到新的目标高度
- 被测量元素没有同时被显式 height 约束
- padding、border 与目标高度的 box model 对得上
工程边界
- Observer 在卸载时已经断开
- CSS transition 与 Motion 没有同时控制 height
- reduced motion 下高度立即完成更新
- 高频内容变化不会持续触发追赶式 spring
- TypeScript、lint 和生产构建通过
收束:先让浏览器排版,再决定怎样移动
auto -> auto 高度动画看起来像一个 Motion API 问题,真正解决它的却是一种更普遍的界面分层思维:
- 内容层负责自然排版
- 测量层负责把布局事实转换成数字
- 动画层负责时间和节奏
- 裁剪层负责守住过渡边界
每一层只做一件事,动态高度就不再需要猜测,也不必把业务状态和 DOM 测量紧紧绑在一起。
并不是所有高度变化都值得加 Observer。简单展开用 0 -> auto,普通重排先试 layout,带 key 的内容切换交给 AnimatePresence。只有当同一内容树真的会在多个自然高度之间变化,并且你需要精确、稳定地控制外框时,再把测量引入进来。
好的界面动效不需要提醒用户“这里做了动画”。它只是让内容变化发生时,布局关系仍然清楚,视线不必因为一次突然跳动而重新寻找位置。