嵌套折叠动画抖动:用 CSS grid-template-rows 彻底告别 JS 测量
在给某后台系统写一个自定义侧边栏树形菜单时,我需要实现多级折叠动画:点击父节点,子菜单平滑展开;子节点本身也可以再展开孙级菜单。第一反应是老办法——用 scrollHeight 测量内容高度,通过 ResizeObserver 驱动 CSS 变量实现过渡。单层跑起来效果不错,但一旦嵌套两层,抖动 bug 就来了。
现象
在父菜单已展开的状态下,点击展开一个子菜单:子菜单自己动画流畅,但父菜单的高度动画明显滞后约一个 transition 周期,在结束时出现一次"弹跳"。越是深层嵌套,越是抖得厉害。
根因
JS 测量方案的机制是:给外层 div 设一个 CSS 自定义属性 --panel-height,transition: height var(--panel-height) 跟着走。ResizeObserver 监听内容区,每次尺寸变化就把新的 scrollHeight 写回 --panel-height。
这在单层时能用,因为内容高度一步到位。嵌套时是致命的:当子菜单在 200ms 内从 0 动画到 N px,内容区的 scrollHeight 每一帧都在增大,ResizeObserver 每帧触发,父级的 --panel-height 每帧被覆写,父级的 transition: height 试图追赶一个不断移动的终点——动画结束后实际高度和目标高度之间存在一帧的偏差,产生可见的"弹跳"。
每帧重写 --panel-height 等于让父级的 transition 追赶一个移动靶。子级动画越长、嵌套越深,父级的抖动越严重。用 setTimeout 延迟写、动画中间临时关掉 transition 等补丁只是掩盖症状,根本无法消除每帧误差。
正解
用纯 CSS grid-template-rows 在 0fr 和 1fr 之间过渡。浏览器自己知道 1fr 对应多少像素——包括内部已经展开的所有子级的高度——整个计算在布局阶段完成,不需要 JS 介入,嵌套天然组合。
{hasChildren && (
<div
className={cn(
"grid transition-[grid-template-rows]",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
style={{
transitionDuration: "200ms",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
{/* 内层 wrapper 必须有 min-h-0 + overflow-hidden,缺一不可 */}
<div className="min-h-0 overflow-hidden">
<ul>{/* 子项,可再嵌套同样结构 */}</ul>
</div>
</div>
)}每个部件的作用:
display: grid+grid-template-rows: 0fr/1fr:让行高由 CSS fr 单位驱动,transition-[grid-template-rows]即可动画。- 内层
min-h-0:grid 子项默认min-height: auto,不设min-h-0则0fr时行高永远不会缩到 0,折叠失效。 - 内层
overflow-hidden:在行高小于内容时裁剪溢出部分,产生"收起"视觉。 - 嵌套免费:子级的
1fr解析为自己的内容高度,父级的1fr把子级已展开的高度自动纳入,不需要父级重新测量。
外层 div 做动画容器(grid transition-[grid-template-rows]),内层 div 加 min-h-0 overflow-hidden 作为裁剪层。展开时切换到 grid-rows-[1fr],折叠时切回 grid-rows-[0fr]。嵌套任意层,零 JS 测量,零抖动。
一句话外卖
折叠动画高度不要用 JS 测,让 CSS
grid-template-rows: 0fr↔1fr接管——浏览器的布局引擎天然处理嵌套,ResizeObserver 永远慢半帧。