返回博客

Base UI 箭头消失之谜:floating-ui 只算一半偏移

给 Tooltip / Popover / HoverCard 写自定义箭头,样式写得没问题,hover 触发后 popup 出现了,箭头却凭空消失。调出 DevTools 才发现箭头方块就在 popup 内部躺着,被 -z-10 死死盖住,从来没探出去过。

现象

<BaseTooltip.Arrow> 内放一个旋转 45° 的方块当尖,四个朝向(top / bottom / left / right)只有一侧看起来"差不多对",其余三侧要么完全不见,要么箭头贴着 popup 内壁显示错位。增大 sideOffset 留出更多间隙反而把问题暴露得更明显——箭头和触发器之间有空白,就是没有桥。

根因

根因藏在 @base-ui-components/react 源码 esm/utils/useAnchorPositioning.js 里:

const arrowStyles = React.useMemo(() => ({
  position: 'absolute',
  top: middlewareData.arrow?.y,   // left/right 侧才有值,top/bottom 侧为 undefined
  left: middlewareData.arrow?.x,  // top/bottom 侧才有值,left/right 侧为 undefined
}), [middlewareData.arrow]);

Base UI 底层用的是 floating-ui 的 arrow middleware,该 middleware 只负责计算交叉轴居中:popup 在上方/下方时只给 left(水平居中),popup 在左侧/右侧时只给 top(垂直居中)。

把箭头从 popup 内部推出边缘的那一轴,floating-ui 压根不管。于是箭头方块停在 popup 内部的默认位置(top: 0, left: 0),被 -z-10 层叠彻底压住,视觉上"消失了"。

Base UI 官方示例的 CSS 里始终带着 [data-side=top] { bottom: -8px } 这类规则,正是在补这个缺失的偏移——只是文档里没有专门解释"为什么要补"。

坑:floating-ui 只算交叉轴,垂直推出那轴它不管

arrowStyles 里永远只有 topleft 其中一个有值(另一个是 undefined),没有 bottom / right。Arrow 方块落在 popup 内部,被 -z-10 盖死。四个 side 只有某一侧"刚好对"也是误判——那只是恰好居中后视觉上没那么明显。

另一个小坑:data-side / data-align 是 Base UI 设在 Arrow 外层 <div> 上的,内层自定义 <span> 拿不到,Tailwind 的 data-[side=top]: 选择器必须写在外层。

正解

在 Arrow 的外层 className 上,按 data-side 补垂直偏移。偏移量 = 方块边长的一半(让方块中心落在 popup 边上,内半藏进去,外半露成尖)。8px 方块用 4px

// 纯色对称菱形(Tooltip 常用,所有 side 旋转角相同)
<BaseTooltip.Arrow
  className={cn(
    "-z-10",
    "data-[side=top]:bottom-[-4px]",
    "data-[side=bottom]:top-[-4px]",
    "data-[side=left]:right-[-4px]",
    "data-[side=right]:left-[-4px]",
  )}
>
  <span className="block h-2 w-2 rotate-45 bg-foreground" />
</BaseTooltip.Arrow>

带方向性 border 的箭头(card 型 popup,需要让特定角续上 popup 边线)稍复杂——方块用 border-b border-r 时 BR 角(右下)天然朝东南(罗盘 135°),要让该角朝向触发器需要按 side 旋转:

<BasePopover.Arrow
  className={cn(
    "-z-10",
    "data-[side=top]:bottom-[-4px] data-[side=top]:rotate-45",          // BR 角朝南
    "data-[side=bottom]:top-[-4px] data-[side=bottom]:rotate-[225deg]", // BR 角朝北
    "data-[side=left]:right-[-4px] data-[side=left]:rotate-[315deg]",   // BR 角朝东
    "data-[side=right]:left-[-4px] data-[side=right]:rotate-[135deg]",  // BR 角朝西
  )}
>
  <span className="block h-2 w-2 border-b border-r border-border bg-surface" />
</BasePopover.Arrow>

旋转量推导公式:目标方向(罗盘角)减去 BR 角初始方向(135°),即 rotate = target - 135°

正解:外层按 data-side 补垂直偏移,带 border 再补旋转

偏移写在 Arrow 外层 className;两轴互不干扰(floating-ui 设的轴 vs 自补的轴正交);Base UI 视口碰撞翻转(flip)后 data-side 跟着变,偏移+旋转自动正确,无需额外处理。

一句话外卖

floating-ui 的 arrow middleware 只算交叉轴居中,垂直于边那一轴必须自己按 data-side——这是 Base UI 所有 overlay 箭头的通用规律,Tooltip / Popover / HoverCard / Menu / Select 全部适用。