返回博客

嵌套折叠动画抖动:用 CSS grid-template-rows 彻底告别 JS 测量

在给某后台系统写一个自定义侧边栏树形菜单时,我需要实现多级折叠动画:点击父节点,子菜单平滑展开;子节点本身也可以再展开孙级菜单。第一反应是老办法——用 scrollHeight 测量内容高度,通过 ResizeObserver 驱动 CSS 变量实现过渡。单层跑起来效果不错,但一旦嵌套两层,抖动 bug 就来了。

现象

在父菜单已展开的状态下,点击展开一个子菜单:子菜单自己动画流畅,但父菜单的高度动画明显滞后约一个 transition 周期,在结束时出现一次"弹跳"。越是深层嵌套,越是抖得厉害。

根因

JS 测量方案的机制是:给外层 div 设一个 CSS 自定义属性 --panel-heighttransition: height var(--panel-height) 跟着走。ResizeObserver 监听内容区,每次尺寸变化就把新的 scrollHeight 写回 --panel-height

这在单层时能用,因为内容高度一步到位。嵌套时是致命的:当子菜单在 200ms 内从 0 动画到 N px,内容区的 scrollHeight 每一帧都在增大,ResizeObserver 每帧触发,父级的 --panel-height 每帧被覆写,父级的 transition: height 试图追赶一个不断移动的终点——动画结束后实际高度和目标高度之间存在一帧的偏差,产生可见的"弹跳"。

坑:ResizeObserver + scrollHeight 在嵌套折叠里是反模式

每帧重写 --panel-height 等于让父级的 transition 追赶一个移动靶。子级动画越长、嵌套越深,父级的抖动越严重。用 setTimeout 延迟写、动画中间临时关掉 transition 等补丁只是掩盖症状,根本无法消除每帧误差。

正解

用纯 CSS grid-template-rows0fr1fr 之间过渡。浏览器自己知道 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-00fr 时行高永远不会缩到 0,折叠失效。
  • 内层 overflow-hidden:在行高小于内容时裁剪溢出部分,产生"收起"视觉。
  • 嵌套免费:子级的 1fr 解析为自己的内容高度,父级的 1fr 把子级已展开的高度自动纳入,不需要父级重新测量。
正解:grid-rows 0fr↔1fr,内层加 min-h-0 overflow-hidden

外层 div 做动画容器(grid transition-[grid-template-rows]),内层 divmin-h-0 overflow-hidden 作为裁剪层。展开时切换到 grid-rows-[1fr],折叠时切回 grid-rows-[0fr]。嵌套任意层,零 JS 测量,零抖动。

一句话外卖

折叠动画高度不要用 JS 测,让 CSS grid-template-rows: 0fr↔1fr 接管——浏览器的布局引擎天然处理嵌套,ResizeObserver 永远慢半帧。