悬浮窗必须用role="dialog"或role="alertdialog"并配aria-modal="true",否则屏幕阅读器无法识别为弹出层;需js实现焦点锁定、动态控制遮罩层aria-hidden、确保触摸与键盘双路径可访问。

悬浮窗必须用role="dialog"或role="alertdialog"声明语义
仅靠position: fixed或z-index无法让屏幕阅读器识别这是“弹出层”。不加ARIA角色,读屏软件会把它当普通块级元素逐字朗读,用户根本不知道该操作、是否可关闭、当前是否处于模态状态。
实际项目中常见错误是只写aria-hidden="true"遮罩背景,却漏掉悬浮窗本身的role和aria-modal="true"。结果是:背景内容被隐藏了,但悬浮窗没被识别为对话框,焦点也不会自动捕获。
-
role="dialog"适用于带表单、按钮、可交互的悬浮窗(如客服入口、设置面板) -
role="alertdialog"适用于无操作、仅提示的轻量弹窗(如“保存成功”) - 必须配
aria-modal="true",否则 Safari 和部分安卓读屏仍会穿透到背景
焦点锁定不是JS可选功能,而是可访问性硬要求
用户用键盘Tab切换时,焦点一旦离开悬浮窗就进后台页面,不仅操作断裂,还可能触发意外行为(比如误点背景里的删除按钮)。这不是体验问题,是WCAG 2.1 A级合规项。
纯CSS做不到焦点锁定,必须用JS监听focusin事件并手动event.target重定向。但别手写循环遍历——浏览器原生document.activeElement和element.focus()配合tabindex="-1"更可靠。
- 首次打开悬浮窗后,立刻
focus()到第一个可聚焦子元素(如button或input) - 监听
focusin,若目标不在悬浮窗DOM树内,立即preventDefault()并focus()回上一个有效焦点元素 - 按
Esc关闭时,必须把焦点还原到触发悬浮窗的原始元素(存document.activeElement快照)
遮罩层aria-hidden要动态控制,不能静态写死
很多人在HTML里直接写<div class="mask" aria-hidden="true">,结果悬浮窗一开,遮罩层确实不可读了,但关闭后<code>aria-hidden="true"还在,导致整个页面对读屏软件不可见。
遮罩层本身不是焦点容器,但它必须随悬浮窗开关实时切换aria-hidden值。更关键的是:它不能抢焦点,所以tabindex必须为-1或干脆不设,且pointer-events: none要配合aria-hidden="true"一起生效。
- 打开悬浮窗:遮罩层
aria-hidden="true"+display: block - 关闭悬浮窗:遮罩层
aria-hidden="false"或移除该属性 +display: none - 绝对不要给遮罩层加
tabindex="0"——它不该接收键盘焦点
移动端触摸与键盘焦点的双重路径必须同时覆盖
在iOS Safari或安卓Chrome里,触摸点击悬浮窗按钮后,焦点不一定跟随。用户再按Tab,焦点可能跳回地址栏或页面顶部,而不是悬浮窗内下一个控件。这会让依赖键盘导航的用户彻底迷失。
解决方案不是禁用触摸,而是确保每次click事件后都显式调用.focus()。尤其注意iOS上button默认不获取焦点,需手动focus();安卓部分WebView则对focus()响应延迟,建议加setTimeout(..., 0)微任务兜底。
- 所有可操作元素(
button、a、input)必须有tabindex="0"或原生可聚焦属性 - 悬浮窗关闭按钮必须同时支持
Enter、Space和点击,且aria-label明确写“关闭客服面板”而非“×” - 避免用
div模拟按钮——即使加了role="button",也缺原生Enter/Space响应逻辑
aria-modal的浏览器兼容性差异。Safari 15.4+才完全支持aria-modal="true"的自动焦点捕获,旧版仍得靠JS补全;而安卓TalkBack对role="alertdialog"的语音播报节奏又和NVDA不同——这些细节不测真机,光看桌面Chrome模拟器永远发现不了。











