返回博客

Base UI Accordion 折叠动画:别量 scrollHeight,用它内置的 CSS 变量

给设计系统写 Accordion 组件时,我的第一反应是:在 useLayoutEffect 里拿 el.scrollHeight,设 height0 到这个值,配合 CSS transition 完工。写了没两行我就停下来了——因为 @base-ui-components/react 根本不需要你这么做。

它已经在内部用 ResizeObserver 量好面板高度,然后把结果挂到面板元素的 style 上作为 CSS 自定义属性。你只需要写 CSS 消费它。

现象

手写 scrollHeight 测量版或者引入 framer-motion AnimatePresence 版,都会遇到不同程度的问题:抖动、unmount 竞态、多个动画库混用的时序冲突。更常见的还有一个症状:折叠时面板明明 height: 0 了,但底部还残留一条 ~32px 的空白带,怎么都收不干净。

根因

Base UI 自己测量并暴露了两个 CSS 变量:

  • --accordion-panel-height(Accordion 用)
  • --collapsible-panel-height(Collapsible 用)

这些变量挂在 Panel 元素的内联 style 上,实时跟随 ResizeObserver 更新。你不需要,也不应该,自己再量一遍。

残留空白带的根因更隐蔽:Tailwind 默认开启 box-sizing: border-box。当你在 Panel 元素本身加了 padding: 16px,同时又把 height 设成 0,浏览器会把内容区压到 0,但 padding 区域不会跟着消失——它会被保留渲染,导致面板看起来有 ~32px 高(上下各 16px padding)。

坑:padding 不能加在 Panel 元素本身

box-sizing: border-box 下,height: 0 + padding: 16px 不会让面板缩到 0。浏览器会保留 padding 区域,视觉上面板仍有高度,折叠动画看起来坏掉了。

正解

三步搞定:

第一步,确认 CSS 变量名(不要靠记忆,rc.0 前后名字可能变):

node -e "console.log(require.resolve('@base-ui-components/react/accordion'))"
# 找到路径后读 panel/AccordionPanelCssVars.js

第二步,Panel 只负责动画,padding 扔给内层 div:

<BaseAccordion.Panel
  style={{
    transitionDuration: motionDurationCss.base,
    transitionTimingFunction: motionEaseCss.out,
  }}
  className={cn(
    "h-[var(--accordion-panel-height)] overflow-hidden transition-[height]",
    // entering: 从 0 长到 panel-height;exiting: 从 panel-height 缩回 0
    "data-[starting-style]:h-0 data-[ending-style]:h-0",
  )}
>
  {/* padding 全在这里,Panel 元素本身不加任何内边距 */}
  <div className="px-4 pb-4 pt-1">{children}</div>
</BaseAccordion.Panel>

data-starting-styledata-ending-style 是 Base UI 在进入/退出时打到元素上的 data 属性,把高度钉在 0,CSS transition 就能从 0--accordion-panel-height 双向运动。transitionDuration / transitionTimingFunction 直接复用设计系统的 motion token,不引入额外运动库。

第三步,Chevron 旋转: Trigger 在展开时带 data-panel-open,配合 Tailwind group 即可:

<BaseAccordion.Trigger className="group ...">
  <span>标题</span>
  <ChevronIcon className="transition-transform group-data-[panel-open]:rotate-180" />
</BaseAccordion.Trigger>
正解:消费库的 CSS 变量,padding 隔离到内层 div

Panel 元素只做高度动画(h-[var(--accordion-panel-height)] overflow-hidden transition-[height]),所有 padding 移到子 div,完全不需要 JS 测量或第三方动画库。

附:rc.0 的 prop 名称变化

多面板同时展开的 prop 名是 multiple(默认 true),不是 openMultiplevalue / defaultValue 接数组;每个 Itemvalue?: any。早期版本文档可能对不上,以 require.resolve 读到的源码为准。

一句话外卖

Base UI 系列组件普遍把计算好的几何量挂成 CSS 自定义属性暴露出来——先确认有没有这个变量,再决定要不要自己量。