Base UI Tabs 滑动指示器:两个让你白忙半天的 API 陷阱
给一个后台管理系统做 Tab 组件换肤,想要那种经典的「激活态指示条滑动」效果——点击不同 tab,底部一根线或者一个圆角胶囊跟着滑过去。选用的是 @base-ui-components/react(rc 阶段),本以为照着文档十分钟搞定,结果被两个完全无声无息的 API 陷阱各坑了一轮。
现象
坑一:给激活 tab 加文字颜色,写 Tailwind 的 data-[selected]:text-foreground,样式毫无反应,TypeScript 编译不报错,浏览器 DevTools 里也找不到对应的 HTML 属性。
坑二:把 <Tabs.Indicator> 放进 <Tabs.List> 里,用绝对定位 + left: 0 做一根横线,组件渲染出来了,但始终黏在最左边不动,无论点哪个 tab 都纹丝不动。脑子里第一反应:「是不是得接 framer-motion 的 layoutId?」
根因
坑一:激活钩子是 data-active,不是 data-selected
Base UI Tabs 的 Tab 组件暴露的激活属性名叫 data-active,对应源码里的 TabsTabDataAttributes:
active="data-active"
disabled="data-disabled"
data-[selected] 是个幽灵——Tailwind JIT 会静默编译通过,HTML 上永远没有这个属性,样式自然无效。
Base UI Tabs.Tab 用的是 data-active,而不是 React Aria / Radix 风格的 data-selected。写 data-[selected]:text-foreground 时 TypeScript 和 Tailwind 均不提示错误,只是默默失效。
坑二:Indicator 只写 CSS 变量,几何驱动全靠你
<Tabs.Indicator> 渲染出来是一个空 <span>,Base UI 内部用 ResizeObserver 测量当前激活 tab 的位置,然后把六个坐标值写在这个 span 的 inline style 上:
--active-tab-left --active-tab-right --active-tab-top
--active-tab-bottom --active-tab-width --active-tab-height
组件本身不会给 span 加任何尺寸或位移——几何完全交给你用 CSS 来消费。不加消费代码,span 就是个透明的 0×0 小点,当然纹丝不动。
<Tabs.Indicator> 不是「会自动跑的指示器」,它是「给你坐标数据的测量结果」。left: 0 是因为你没有消费 --active-tab-left,不是 bug。
正解
激活样式
// 正确:data-[active]
<BaseTabs.Tab className="text-muted data-[active]:text-foreground data-[disabled]:opacity-50 ..." />下划线条样式(underline bar)
<Tabs.List> 必须是 position: relative,Indicator 是其 DOM 后代(Base UI 的预注水脚本通过 closest('[role="tablist"]') 定位):
<BaseTabs.Indicator
className="pointer-events-none absolute bottom-0 left-0 h-0.5 rounded-full bg-primary"
style={{
transitionProperty: "transform, width",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
width: "var(--active-tab-width)",
transform: "translateX(var(--active-tab-left))",
}}
/>胶囊分段样式(segmented pill)
// List 加底色 + 内边距;Tab 加 relative z-10 让文字浮在胶囊上方
<BaseTabs.Indicator
className="pointer-events-none absolute left-0 top-0 rounded-md bg-surface shadow-sm"
style={{
transitionProperty: "transform, width, height",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
width: "var(--active-tab-width)",
height: "var(--active-tab-height)",
transform: "translate(var(--active-tab-left), var(--active-tab-top))",
}}
/>Base UI 已经替你量好了坐标,CSS transition 直接插值即可平滑滑动。无需引入 framer-motion / motion,也不存在 SSR 闪烁问题——指示器在 width > 0 && height > 0 前保持 hidden,首屏不会从左边飞过来。
TabsIndicator.js 内部对 ResizeObserver 做了 typeof 守卫,jsdom 环境下直接跳过测量,不需要 polyfill。几何正确性无法在 jsdom 里验证(没有 layout),用 getBoundingClientRect 或截图来做集成验收。
一句话外卖
Base UI Tabs 的 API 命名和数据约定都需要读源码确认,
data-active不是data-selected,Indicator是几何数据源不是自动动画——两条规则记住,motion 库就不用装了。