返回博客

Base UI 导航菜单面板塌缩成 2×2px 的根因:Content 不能脱离文档流

给导航栏做 mega 菜单时,我用了 Base UI 的 NavigationMenu。面板动画看起来很顺, 然而上线后用户反映菜单「点了没反应」——仔细检查才发现触发器的 chevron 确实旋转了( data-popup-open 上去了),DOM 里内容也在,就是肉眼完全看不见

现象

打开 DevTools,选中 Popup 元素:

// 控制台直接量一下
const popup = [...document.querySelectorAll('*')]
  .find(e => (e.style.width || '').includes('--popup-width'));
console.log(JSON.stringify(popup.getBoundingClientRect()));
// 输出:{"x":120,"y":64,"width":2,"height":2,"top":64,"right":122,"bottom":66,"left":120}

宽 2、高 2。再看计算样式:--popup-width: auto--popup-height: auto—— 这两个 CSS 变量从没写入像素值,Popup 的 width: var(--popup-width) 于是退化为 width: auto,内容全部溢出到 overflow: hidden 区域之外,面板肉眼消失。

构建和单元测试全部绿,因为它们只断言 DOM 存在或类名,从不量 bounding box

根因

Base UI 的 Popup 尺寸变形(size morph)依赖一次主动测量

  1. Trigger 被激活 → 把 --popup-width/height 先置为 auto
  2. 调用 getCssDimensions(popupElement) 读取 Popup 的自然尺寸
  3. 把读到的 px 值写回 --popup-width/height,再触发 CSS transition 做大小动画
  4. 动画结束 → 重置回 auto(让 Popup 跟内容尺寸)

第 2 步的测量结果完全取决于 Popup 此刻能撑多大——而这正由其子元素 Content 贡献。如果 Content 被设成 position: absolute,它就脱离了文档流,不参与 父元素的高度计算,Popup 自然尺寸归零,测量结果 ≈ 0,于是 --popup-width/height 被写入接近 0 的值,面板塌缩。

坑:给 Content 加 position:absolute 会破坏 Base UI 的尺寸测量

直觉上「面板是浮层,多个 Content 切换时应该叠加,自然要用 absolute」——但 Base UI 的 size morph 机制要求当前激活的 Content 必须留在文档流中,以便 Popup 能 量到真实尺寸。一旦把它设成 absolute,Popup 测出宽高为 0,CSS 变量写入 0 px, overflow: hidden 把面板彻底裁掉,症状就是「内容挂了但面板不可见」。

正解

只把正在退出(exiting)的 Content 设为 absolute,当前激活的那个保持 in-flow:

// ❌ 两个状态都 absolute → 当前 Content 也脱流 → Popup 量到 0
<NavigationMenu.Content className="absolute left-0 top-0 w-full p-6" />
 
// ✅ 当前 Content 在流内;仅 data-ending-style 时脱流
<NavigationMenu.Content
  style={{ transition: `opacity 200ms ease, transform 200ms ease` }}
  className={cn(
    // 留在流内,让 Popup 自然撑开
    "w-max max-w-[calc(100vw-2rem)] p-6",
    // 仅退出时脱流,不影响当前内容的尺寸贡献
    "data-[ending-style]:absolute data-[ending-style]:left-0 data-[ending-style]:top-0",
    // 进入/退出淡化
    "data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
  )}
/>

同时确保:

  • Popupoverflow-hidden(裁剪 size morph 过渡期的溢出),并绑定 width: var(--popup-width); height: var(--popup-height) + width/height transition
  • Viewportposition: relative(为退出时的 absolute Content 提供定位上下文)
  • 位移动画(left/right/top/bottom)放在 Positioner,不要放在 Popup

修复后再量:

JSON.stringify(popup.getBoundingClientRect());
// {"x":120,"y":64,"width":482,"height":146,...}  ✅
正解:激活态 Content 保持 in-flow,退出态才 absolute

position: absolute 限制在 data-[ending-style],进入/激活状态的 Content 始终在文档流中。这样 getCssDimensions 每次都能量到真实内容尺寸,size morph 的 CSS 变量得到正确像素值,面板正常显示。

排查备忘

这类坑单元测试和 SSG 构建都不会暴露,因为它们不量几何。遇到「内容挂载但面板 不可见」时,先在 DevTools 或 CDP 里量 Popup 的 getBoundingClientRect(), 如果宽高接近 0 并且 --popup-width/height 还是 auto,基本可以断定是 getCssDimensions 量到了 0——往 Content 的 position 检查。

一句话外卖

Base UI 通过测量 Popup 的自然尺寸驱动 size morph,绝不能让激活态的 Content 脱离文档流;只把退出态设为 absolute,入场态留在流内。