返回博客

滑动胶囊指示器:反色文字必须住在胶囊里,胶囊必须在按钮层之上

做分页条、Tab 切换、分段控件时,我们经常想要一个「平滑滑动的实心胶囊」来高亮当前项,并且选中项的文字要反色(比如深色文字变为白色压在品牌色胶囊上)。逻辑上很简单:一个绝对定位的 <span> 测量目标按钮的 offsetLeft / offsetWidth,加 CSS transition 让它滑过去。结果上线一看——踩了两个相互独立、都很隐蔽的坑。

现象

症状 A:颜色不同步。 胶囊滑到位了,文字还是黑的;再等一个动画周期,文字才变白。视觉上是「蓝色底先到,黑色数字晚到」的闪烁。

症状 B:明暗主题表现不一致。 暗色主题里一切正常,切到亮色主题后,点击新的选项,胶囊的品牌色消失了,只剩一个灰色的 hover 框;或者品牌色底上压了一片黑色文字,就是不反色。

根因

坑一:文字颜色和胶囊是两个独立动画

最直觉的写法是:按钮本身判断 isActive 就加 text-white,胶囊只负责移动。这样文字颜色的变化是由 React 状态切换触发的 className 变更,而胶囊的移动是 CSS transition 驱动的。两者时序不同——状态更新是同步的(或下一帧),CSS transition 是 200ms 后才结束——于是就出现了「胶囊还没到,文字已经变白」或「胶囊到了,文字还没变」的撕裂感。

坑:反色文字挂在按钮上,和胶囊是两个不同的动画

按钮的 text-white 由 state 驱动,胶囊的位移由 CSS transition 驱动。无论怎么调 duration / easing,它们本质上是两条独立的时间线,永远对不齐。

坑二:z-index: auto 输给了有具体 z 值的兄弟节点

胶囊用 position: absolute 绝对定位,没有指定 z-index,所以是 auto。而每个按钮是 relative z-10auto 不参与层叠上下文比较,会输给任何带有数字 z-index 的兄弟——胶囊被按钮压在下面了。

为什么暗色主题"意外正常"?因为暗色主题里 foreground token 是白色,按钮文字本身就是白色。胶囊被压在按钮后面,但透过透明按钮看起来像「白字压在蓝底上」,视觉上正确。一切换到亮色主题,foreground 变成黑色,黑字压蓝底,加上按钮的 hover:bg 完全遮住胶囊,品牌色就「消失」了。

坑:缺一个 z-20,暗色主题掩盖了整个 bug

position: absolute 不等于在最上层。没有显式 z-index 数值时,绝对定位元素会输给任何带具体 z 值的兄弟。暗色主题因为颜色巧合"看起来正常",导致这个 bug 在亮色主题才暴露。

正解

1. 把反色文字搬进胶囊

胶囊本身渲染当前选中项的文字标签,携带 bg-primary text-primary-foreground。背景色和文字同属一个 DOM 节点,同一个 transition,物理上不可能不同步。

按钮保留原始(深色)文字,被胶囊覆盖时自然消失。给胶囊加 pointer-events-none aria-hidden,点击穿透到下方按钮,无障碍树读按钮原始文字。

2. 给胶囊一个明确的 z-index,高于按钮

按钮是 relative z-10,胶囊必须 z-20(或更高)。这是唯一的修复,一个 class 的事。

3. SSR / 首帧前的降级

offsetWidth 在 SSR 和 jsdom 里是 0,胶囊无法定位。此时不渲染胶囊,把激活按钮的 variant 降级为 solid(实心自填色),保证首屏有视觉反馈。客户端测量完成后,切换按钮为 ghost、渲染胶囊——位置和颜色已经对齐,过渡无缝。

正解:胶囊携带白色标签 + 显式 z-20 + SSR 降级

三件事一起做:文字进胶囊、胶囊加 z-20、未测量时按钮降级 solid。颜色同步问题和层叠问题同时消除。

// 胶囊:携带白色标签,z-20 压在所有按钮之上
{hasPill && (
  <span
    aria-hidden
    className="pointer-events-none absolute left-0 top-0 z-20 flex h-9
               items-center justify-center rounded-[var(--radius)]
               bg-primary text-sm font-medium text-primary-foreground shadow-sm"
    style={{
      width,
      transform: `translateX(${left}px)`,
      transitionProperty: "transform, width",
      transitionDuration: "200ms",
      transitionTimingFunction: "cubic-bezier(0.16,1,0.3,1)",
    }}
  >
    {currentLabel}
  </span>
)}
 
// 按钮:胶囊未就绪时降级为 solid,就绪后保持 ghost
<button
  className={
    buttonVariants({ variant: active && !hasPill ? "solid" : "ghost" })
    + " relative z-10"
  }
>
  {label}
</button>

验证要点

用真实浏览器(不是无头截图)在亮色和暗色主题各自点击切换一次,而不只看初始加载状态——SSR 降级的 solid 按钮会掩盖首屏的层叠 bug。用 getComputedStyle(pill).zIndex 确认返回数字而非 "auto"

一句话外卖

反色文字必须和胶囊同节点移动,胶囊必须有显式 z-index 数值——缺一 class 的 bug 会被暗色主题完美掩盖,等你上线才现形。