返回博客

Base UI Slider 焦点环怎么都不出来——:focus-visible 打在了不接收焦点的元素上

在一个设计系统组件库里,我用 Base UI 的 Slider.Thumb 封装了一个无障碍滑块。按照 Switch.Root 的经验,在 thumb div 上加了 focus-visible:ring-2 ring-ring ring-offset-2,期待键盘 Tab 过去时出现焦点环。

但不管怎么操作,焦点环就是不出现。类名在,Tailwind 编译了,token 也正确——肉眼看毫无反应。

现象

  • 鼠标点击 Slider 正常工作,拖拽也流畅
  • 键盘 Tab 定位到 Slider 后,焦点环消失,无任何视觉反馈
  • DevTools 里检查 thumb div,class 属性里有 focus-visible:ring-2,但 Computed 面板里 box-shadow 为空
  • document.activeElement 指向的不是 thumb div,而是藏在里面的 <input type="range">

根因

Base UI(rc.x)的 Slider.Thumb 渲染结构是这样的:

<div class="thumb-div ...">          <!-- 这是你加样式的地方 -->
  <input type="range" style="...">   <!-- 这才是真正的焦点目标 -->
</div>

内层 <input type="range"> 是 visually-hidden 的真实控件,Tab 键聚焦时 document.activeElement 落在它身上,而不是外层 div。CSS 伪类 :focus-visible 只评估元素自身是否获焦,所以加在 div 上的 focus-visible: 选择器永远不匹配——div 从来不接收焦点。

坑:把焦点状态类打在了「从不获焦的包裹层」上

Switch.Root 的 <button> 自身就是焦点目标,所以 focus-visible: 在那里能工作。但 Slider.Thumb 的情况不同:真正获焦的是内层 <input>,外层 div 永远不会触发 :focus-visible

诊断方法:聚焦后执行 getComputedStyle(thumbDiv).boxShadow——如果是空字符串,说明你还在用 :self 选择器的思路,需要换 has-[] 方案。

正解

用 CSS :has() 选择器向上查询:在包裹层监听内层元素的状态。Tailwind v4 的 has-* 变体直接支持:

const thumbCls = cn(
  "size-4 rounded-full border border-border bg-surface shadow outline-none transition-transform",
  // 监听内层 input 的 :focus-visible,把 ring 渲染在外层 wrapper 上
  "has-[:focus-visible]:ring-2",
  "has-[:focus-visible]:ring-ring",
  "has-[:focus-visible]:ring-offset-2",
  "has-[:focus-visible]:ring-offset-bg",
);
// <Slider.Thumb className={thumbCls} />

验证:

const input = document.querySelector('input[type=range]');
const thumb = input.closest('[class*="rounded-full"]');
input.focus();
getComputedStyle(thumb).boxShadow;
// → "... 0 0 0 2px <offset-color>, 0 0 0 4px <ring-color> ..."
// 两层 box-shadow = ring + ring-offset,代表焦点环正常渲染
正解:wrapper 用 `has-[:focus-visible]:`,不是 `focus-visible:`

判断规则只有一条:聚焦后 document.activeElement 是你加样式的元素本身,还是它内部的某个子元素?

  • 是自身(Switch.Root <button>、Checkbox.Root / Radio.Root <span>)→ 直接用 focus-visible:
  • 是内层元素(Slider.Thumb 的 <input type="range">)→ 用 has-[:focus-visible]: 在 wrapper 上

反例:Checkbox / Radio 不需要这个修法

容易踩的第二个坑是「一刀切」:看到 Slider.Thumb 要用 has-[],就把 Checkbox.Root / Radio.Root 也改了。

这是错的。 Checkbox.Root 和 Radio.Root 渲染的是一个带 role=checkbox/role=radio + tabindex<span>,Tab 键聚焦后 document.activeElement 就是那个 span 本身,不是内层 input。它们的 data-[checked]data-[disabled] 状态也直接挂在该 span 上,所以 focus-visible:ring-2 + data-[checked]:bg-primary 直接写就行,不需要 has-[]

一句话外卖

:focus-visible / :checked 等状态伪类前,先确认 document.activeElement 到底落在哪个元素上——是自身就直接写,是内层子元素就用 has-[:state] 把查询委托给包裹层。