用键盘操作自己的仪表盘,每点一个链接,页面变了,但下一次按 Tab 键,光标又回到了站点 Logo。这不是某个人的手滑,而是 Next.js App Router 客户端路由的一个默认行为:它通知了屏幕阅读器,却没有移动键盘焦点。
发现这个问题的过程很朴素——不是靠审计工具,而是把鼠标拔掉一个下午。对很多产品来说,这类问题从 2025 年 6 月 28 日《欧洲无障碍法案》适用之后,就不再只是手艺问题。
![]()
它只做了一半的事
Next.js 会在 DOM 里渲染一个路由播报元素:一个视觉隐藏、带 aria-live 的区域。每次客户端导航时,它把新页面的名字写进去,先读 document.title,读不到就退回页面 h1 的文本。屏幕阅读器用户因此能听到"有东西变了"。
剩下的两个缺口要应用代码自己补。第一个缺口是:播报的质量取决于标题本身。在流式渲染的分段里,由 generateMetadata 延迟解析出的标题,可能在播报器触发之后才到位,于是被读出来的可能是上一个页面的名字。每个路由配一个独立的、静态解析的标题,才是可靠版本。
第二个缺口更大,它就是焦点。
焦点本该落在新内容顶部
完整的浏览器导航会免费做到这件事:焦点重置到文档开头。客户端导航不会,因为在浏览器看来什么都没导航——只是 React 替换了一些节点。用户激活的那个链接正是被替换的节点之一,于是焦点掉到 body 上,顺序导航从页面顶部重新开始,每点一次都要把整个页头再走一遍。
修法是一个十五行的客户端组件,加一个 tabIndex={-1}。这十五行里有三个细节值得留下:
- 跳过首次渲染——初次加载时浏览器已经把焦点放在文档开头,再抢一次只是噪音。
- preventScroll: true——Next.js 会在导航间恢复滚动位置,不加保护的 .focus() 会把容器滚进视野,和这个恢复过程打架。
- tabIndex={-1}——它让一个普通 div 能被脚本聚焦,同时不进入 Tab 顺序;没有它,.focus() 会静默失效。
包裹的对象应该是布局的主区域,而不是页面本身,这样在 loading.tsx 边界渲染期间,焦点目标依然存在。它上方那个跳转链接解决的是同一个问题的另一半,有两个要求:必须是 DOM 里第一个可聚焦元素,并且获得焦点时必须可见。指向 #main 的跳转链接只有在目标能接收焦点时才有效——又是 tabIndex={-1} 那条规则。
原生 dialog 已经内置了焦点陷阱
用原生 dialog 元素,并用 showModal() 打开它。HTMLDialogElement.showModal() 会把对话框放进浏览器的顶层,渲染 ::backdrop 伪元素,让它之外的一切变成 inert,并且按 Escape 时触发 cancel 事件关闭。那些焦点陷阱库用 JavaScript 重新实现的功能,大部分就在这里。
陷阱来自方法调用,不来自标记。声明式渲染的 dialog open 是非模态对话框:没有顶层,没有背景遮罩,没有焦点陷阱。在 React 里这意味着要用 effect 驱动它,而不是用 JSX。
还有两个行为需要自己写代码。初始焦点会落在第一个可聚焦后代上,通常是个关闭按钮,所以要把 autoFocus 放在真正想要的那个元素上。关闭时焦点不会恢复:要在调用 showModal() 之前记下 document.activeElement,在 close() 之后对它调用 .focus()。
popover 属性适合菜单、提示和非阻塞面板,恰恰因为它不困住焦点。拿它去做确认对话框,是现在最常见的一个错误——两个特性发布的时间太近,容易让人觉得可以互换。
当用户完全不该触达某块内容时用 inert,只有当内容可见但对辅助技术冗余时才用 aria-hidden。两者不能互换。aria-hidden="true" 会把一棵子树从无障碍树里藏起来,却让里面每一个按钮仍然可以被 Tab 到——这会产生最糟糕的结果。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.