HoverCard 无限闪烁:焦点乒乓死循环与 initialFocus= 的救场
某个管理后台需要一张富信息悬停卡片(HoverCard),在鼠标悬停时弹出详细信息。由于所用的组件库(Base UI rc.0)没有内置 openOnHover,我选择在 Popover.Root 上手动接线:触发器的 onMouseEnter / onFocus → scheduleOpen,onMouseLeave / onBlur → scheduleClose。逻辑听起来无懈可击,实测一放鼠标——卡片以肉眼可见的频率反复开关,控制台里 open 的变化序列是 0,1,0,1,0,1,...,永无停歇。
现象
- 鼠标悬停触发器,HoverCard 疯狂闪烁,无法稳定展示。
- 把鼠标停在触发器上完全没用,卡片始终在「刚打开就关掉」的循环里。
- 把
onFocus/onBlur拆掉之后,鼠标路径下的闪烁消失——但键盘用户彻底无法使用。
根因
Base UI Popover(以及 Radix Popover/Dialog、任何带 focus trap 的浮层原语)在打开时会把焦点自动移入弹层,关闭时再把焦点还给触发器。这是为 Modal 类交互设计的无障碍行为,用在 HoverCard 上却造成死循环:
- 触发器获得焦点 →
onFocus→open = true - Popover 打开,焦点移入弹层 → 触发器触发
onBlur→open = false - Popover 关闭,焦点还给触发器 →
onFocus→open = true - 回到第 2 步,无限往复
即便完全靠鼠标操作,onMouseEnter 打开弹层 → 弹层抢焦点 → 触发器 onBlur → 关闭 → 焦点还回来 → onFocus 再开——同样中招。
HoverCard 是只读的信息展示面(类 Tooltip 语义),用户不需要把焦点移进去操作。但底层 Popover 原语默认会在 open 时把焦点挪进弹层、close 时还给触发器——这个「善意」的无障碍行为,配上触发器的 onFocus/onBlur 处理器,构成完美死循环。
调延迟(closeDelay / openDelay)、调 sideOffset、改 modal={false} 都无法打破这个循环,因为根本原因是焦点在触发器和弹层之间来回传递。
正解
在弹层元素上同时关掉两个方向的焦点移动,让焦点始终留在触发器上:
<BasePopover.Popup
initialFocus={false} // open 时:不把焦点移入弹层
finalFocus={false} // close 时:不把焦点还给触发器
onMouseEnter={ctx.open}
onMouseLeave={ctx.close}
>
{/* HoverCard 内容 */}
</BasePopover.Popup>如果使用 Radix UI,对应的写法是在 Content 上阻止默认行为:
<PopoverContent
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>关掉两个方向后,焦点的状态变为:
- 鼠标悬停 → 卡片打开,焦点留在触发器,不触发
onBlur,循环断开 - 键盘 Tab 到触发器 →
onFocus→ 卡片打开,焦点留在触发器(Tooltip 语义);Tab 离开 →onBlur→ 卡片关闭,行为符合预期
initialFocus={false} 阻止开启时抢焦点,finalFocus={false} 阻止关闭时还焦点,两个方向都切掉,焦点全程钉在触发器上。HoverCard 从此安静展示,键盘无障碍也保留完好(Tab 到触发器开、Tab 离开关)。
排查陷阱:Playwright 看不到这个 bug
用 Playwright 自动化测试时,mouse.move() 模拟鼠标悬停,但合成鼠标事件不会真正给触发器 DOM 分配焦点,所以在自动化测试里一切看起来都正常。想复现这个 bug,必须走焦点路径:
await page.evaluate(() => triggerButton.focus()); // 驱动 focus 路径才能触发死循环这个「自动化看不见、真实用户立刻踩」的差异,是排查悬停类 bug 时要优先测试焦点路径的信号。
一句话外卖
把 HoverCard 搭在 Popover 原语上时,一定要关掉弹层的焦点劫持(
initialFocus={false}+finalFocus={false});Modal 级焦点管理是 Tooltip 类场景的隐形炸弹。