aria-describedby + role="tooltip" 是可访问气泡的最小可行组合:语义上明确提示类型,行为上支持键盘聚焦触发;需同步控制 aria-hidden、绑定 focusin/mouseenter、适配移动端点击/长按,并用 getBoundingClientRect() 计算位置。

aria-describedby + role="tooltip" 是可访问气泡的最小可行组合
仅靠 title 或纯 CSS 气泡,屏幕阅读器无法稳定识别内容,键盘用户也无从触发。真正可访问的气泡必须满足两个硬性条件:一是语义上明确这是 tooltip,二是行为上能被键盘焦点驱动。
核心做法是用 aria-describedby 把触发元素(如按钮)和提示容器关联起来,再给提示容器加上 role="tooltip"。浏览器和主流屏幕阅读器(NVDA、VoiceOver)会据此建立语义链,当焦点落在按钮上时,自动读出提示文本。
示例结构:
<button id="save-btn" aria-describedby="save-tip">保存</button> <div id="save-tip" role="tooltip" aria-hidden="true">点击后将保存当前表单,并跳转至成功页</div>
注意:aria-hidden="true" 必须初始存在,否则未显示时也会被读出;JS 显示气泡时需同步设为 false。
立即学习“前端免费学习笔记(深入)”;
鼠标悬停不能替代键盘聚焦,但必须保持行为一致
很多团队只做 :hover 显示,却没处理 focus 触发——这会让键盘用户完全错过提示。正确做法是:鼠标悬停和键盘聚焦(Tab 进入)都应触发气泡显示,且隐藏逻辑也要对齐:失去焦点(blur)或 ESC 键按下时关闭。
常见错误包括:
- 用
mouseover替代mouseenter,导致子元素移入移出反复触发 - 只监听
focus却忽略focusin(后者能捕获冒泡焦点,更可靠) - 气泡元素自身加了
tabindex="0",打断用户 tab 流程
推荐绑定方式:
btn.addEventListener('mouseenter', showTooltip);
btn.addEventListener('focusin', showTooltip);
document.addEventListener('focusout', hideOnBlur);
document.addEventListener('keydown', handleEscape);
移动端与触摸设备没有 hover,别假装它存在
iOS Safari、Android Chrome 等主流移动浏览器默认禁用 title 和 CSS :hover 气泡。如果你只依赖悬停,等于在移动端直接丢弃提示功能。
真实可行的方案只有两个:
- 改用点击触发(
click或touchstart),并支持再次点击/外部点击关闭 - 保留悬停逻辑,但同时为触摸设备 fallback 到长按(
touchstart + setTimeout)或显式“i”图标点击
不要试图用 @media (hover: hover) 做开关——它在 iOS 上返回 hover 但实际不触发,反而制造假象。
气泡位置计算必须用 getBoundingClientRect(),不是 offsetTop
很多 JS 气泡库仍用 offsetTop/offsetLeft 计算位置,结果在滚动页面、缩放视图、iframe 嵌套或使用 transform 的父容器里严重偏移。
getBoundingClientRect() 返回的是相对于视口的位置,天然适配滚动、缩放、CSS transform,且兼容所有现代浏览器。
关键代码片段:
const rect = triggerEl.getBoundingClientRect();
tooltipEl.style.left = `${rect.left + window.scrollX}px`;
tooltipEl.style.top = `${rect.bottom + window.scrollY + 4}px`;
注意:不要直接用 rect.left 赋值给 left,要加上 window.scrollX/Y ——getBoundingClientRect() 不包含滚动偏移,这是最容易漏掉的一环。
最常被忽略的点是焦点管理:气泡显示后,focus() 到气泡本身是错的;正确的做法是在隐藏时把焦点还给原触发元素,并确保 ESC 关闭后焦点不丢失到 body。这点在复杂表单中直接影响键盘用户的操作连贯性。



















