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,代表焦点环正常渲染判断规则只有一条:聚焦后 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]把查询委托给包裹层。