先描述一个现象,看看你是否有同感。
移动端实现一个侧滑抽屉菜单,从左侧滑出来,底部盖一层半透明遮罩。看起来挺正常。但如果你用键盘 Tab 键去点,会发现几个诡异的事情:焦点可以跑到抽屉背后被遮住的主页面元素上;Tab 键一直按下去会绕一圈,从抽屉里的最后一个元素跳到抽屉的第一个元素,而不是停在抽屉内部;屏幕阅读器会读出抽屉背后页面的所有内容,用户听了一头雾水。
这是一个典型的”视觉上看起来正常,但无障碍完全崩掉”的场景。
解决这个问题的传统方法是手写焦点陷阱(focus trap)——监听键盘事件,捕捉 Tab 和 Shift+Tab,手动计算当前焦点的位置,还要不要在边界上循环,还要处理屏幕阅读器通过虚拟光标导航的情况。整套实现下来一百多行 JS,还会有各种边界 bug。
2023 年之后,这一切其实可以变得非常简单:HTML 的 inert 属性。
inert 是什么
一句话说清:当一个元素带上 inert 属性时,它以及它的所有后代元素,都会变成”不可交互”的状态。
所谓”不可交互”,具体包含这几层:
- 不能通过鼠标点击
- 不能通过键盘聚焦(Tab 键会跳过它)
- 不能通过程序
element.focus()聚焦 - 不会出现在无障碍树(accessibility tree)中,屏幕阅读器读不到
- 它上面的点击事件、鼠标事件都会被忽略
用一个属性,把一整个 DOM 子树”冻结”起来了。
最朴素的用法:
<div id="page" inert>
<p>这个区域现在完全不可交互</p>
<button>点不动的按钮</button>
</div>
移除 inert,一切恢复正常。可以在 JS 里这样控制:
page.inert = true // 冻结
page.inert = false // 恢复
注意这里用的是 DOM 属性 element.inert,是一个布尔值,不是 setAttribute('inert', '')。虽然两者效果一样,但用 .inert 更简洁,也更符合现代 API 习惯。
回到抽屉菜单那个场景
先看老的实现方案。焦点陷阱的核心逻辑大概是这样的:
// 伪代码,示意用
function trapFocus(container) {
const focusable = container.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
})
}
这段代码只是”核心”。还没算上:
- 打开抽屉时把焦点主动移入第一个可聚焦元素
- 关闭抽屉时把焦点还给触发按钮
- 点击遮罩关闭时也要触发焦点回迁
- 处理
focusin/focusout事件,防止鼠标点击或者程序调用让焦点溜出去 - 屏幕阅读器只读抽屉内容,不读背后的
- 多个嵌套抽屉或弹出层的 z-index 和焦点管理冲突
真正做完整,一百行都打不住。而且,这套代码是”徒手”实现的,跟浏览器引擎对不上,遇到 Safari、老版本 Chrome 各种兼容问题,往往调试成本比写代码还高。
用 inert,可以简化成这样:
<body>
<div id="app">
<!-- 主页面内容 -->
</div>
<aside id="drawer">
<!-- 抽屉内容 -->
<button id="closeBtn">关闭</button>
</aside>
</body>
const app = document.getElementById('app')
const drawer = document.getElementById('drawer')
function openDrawer() {
drawer.classList.add('open')
app.inert = true // 主页面冻结
closeBtn.focus() // 焦点移入抽屉
}
function closeDrawer() {
drawer.classList.remove('open')
app.inert = false // 主页面恢复
triggerButton.focus() // 焦点还给触发按钮
}
就这么多。
Tab 键不会再跑到抽屉背后的元素上了,因为 #app 整棵子树都变成了不可聚焦。element.focus() 想强塞也塞不进去,会被浏览器直接忽略。屏幕阅读器也不会再读主页面内容了。
这一整套逻辑,收敛成了两个布尔赋值。这就是 inert 的价值。
跟 aria-hidden 有什么区别
很多人会想:aria-hidden="true" 不也是干这个的吗?其实差别挺大。
| 对比项 | aria-hidden=”true” | inert |
|---|---|---|
| 影响屏幕阅读器 | ✅ 会从无障碍树移除 | ✅ 会从无障碍树移除 |
| 影响键盘焦点 | ❌ Tab 键仍能聚焦 | ✅ Tab 键会跳过 |
| 影响鼠标点击 | ❌ 仍然可以点击 | ✅ 会忽略 |
| 递归影响子元素 | ✅ 是 | ✅ 是 |
| 能否通过程序聚焦 | ✅ 可以(但如果同时有 aria-hidden 会被忽略) | ❌ 不可以 |
关键差别在中间两行。aria-hidden 只是”告诉屏幕阅读器别读这个”,但键盘用户和鼠标用户依然可以操作到那个区域。所以在无障碍的完整意义上,aria-hidden 只是解决了三层问题中的一层。
我们项目之前就踩过一个坑:抽屉打开时给主页面加了 aria-hidden="true",屏幕阅读器确实不读了,但键盘用户还是能用 Tab 键跑到主页面元素上。用户反馈”抽屉打开时按 Tab 键后会突然失去方向感”,就是因为焦点跑到被 aria-hidden 标记了的元素上——这在无障碍规范里是”非法状态”,浏览器行为不一致,有的会给警告,有的会强制把焦点拉回来,用户体验非常混乱。
正确做法是:如果只想让屏幕阅读器忽略,用 aria-hidden;如果想彻底冻结可交互性,用 inert(它同时也影响屏幕阅读器)。
和 dialog 元素配合
原生 <dialog> 元素调用 showModal() 的时候,浏览器内部就是用类似 inert 的机制把主页面冻结了的。所以普通的模态框场景,用 dialog 自带的能力就够了,不需要手动 inert。
但两种场景,手动 inert 还是必须的:
第一种:非 dialog 的模态层。用自定义元素实现的全屏模态、侧边抽屉、底部的操作面板,这些视觉上像模态,但不是 dialog。就需要自己管 inert。
第二种:dialog 内嵌复杂布局。比如 dialog 里有一个分步表单,第二步还没显示时,第二步的内容应该被冻结。这时需要在 dialog 内部对某些区块使用 inert。
<dialog id="wizard">
<section id="step1">第一步内容</section>
<section id="step2" inert>第二步内容</section>
<section id="step3" inert>第三步内容</section>
</dialog>
每一步切换时,把当前步的 inert 去掉,其他步加上 inert。这样 Tab 键只会停在当前步骤的元素上,用户不会突然切到还没到的步骤去输入。
嵌套场景
inert 是继承性的。一个元素有 inert,它的所有后代都不可交互。这带来一个有意思的效果:嵌套场景下,管理变得简单。
假设一个仪表盘页面,主区域里打开了一个侧边抽屉,抽屉里又打开了一个全屏确认框。三层结构。
用老方案,每层都要独立管理 z-index 和焦点陷阱,还得处理关闭时的层级回退。
用 inert:
// 打开抽屉
main.inert = true
// 打开抽屉里的确认框
drawer.inert = true
// 关闭确认框
drawer.inert = false
// 关闭抽屉
main.inert = false
每层只管理自己上一层,不关心更外面是什么状态。main.inert = true 之后,整个子树都冻结,包括已经冻结的 drawer。反过来解冻时,需要按顺序还原,因为 drawer.inert = false 只在 drawer 自己身上生效,不会改变 main 的状态。
这带来的一个实践建议:每个模态组件只负责管理自己”上一层”的 inert 状态,把状态管理交给组件的生命周期。这样多层级嵌套时不会互相干扰。
浏览器支持情况和兼容处理
inert 属性 2020 年进入规范,2023 年所有主流浏览器完成了支持。具体版本:
| 浏览器 | 支持版本 |
|---|---|
| Chrome / Edge | 102+(2022 年) |
| Firefox | 112+(2023 年) |
| Safari | 15.5+(2022 年) |
| Safari iOS | 15.5+ |
| Chrome Android | 102+ |
2024 年之后新建的项目基本不用担心兼容性。如果真的要兼容更老的浏览器(IE、早期 Safari),可以引入官方的 polyfill:
<script src="https://unpkg.com/inert-polyfill@0.1.7/dist/inert-polyfill.min.js"></script>
引入之后就可以像原生一样用 element.inert = true,polyfill 会模拟 inert 的行为。它的实现方式是给元素加 aria-hidden="true",然后在键盘事件上做拦截,在内部给所有可聚焦元素加 tabindex="-1"。效果上接近原生,但有性能开销(每次 toggle 都要遍历子元素),复杂 DOM 树的时候比较慢。
我的建议:现在的项目直接依赖原生,不做兼容。真遇到老浏览器,用 feature detection 降级到老的焦点陷阱方案:
if (!('inert' in HTMLElement.prototype)) {
// 走 legacy 路径
setupFocusTrap(container)
} else {
// 原生 inert 路径
page.inert = true
}
我们踩到的六个坑
坑一:不要给 body 加 inert。第一版实现的时候,我一激动直接 document.body.inert = true,想一次性冻结整个页面。结果整个页面都变成不可交互了,连抽屉打开按钮自己都点不了(因为抽屉也是 body 的一部分)。inert 应该加在”被抽屉遮住的那部分”上,加在 body 上的时候,抽屉不能是 body 的后代。
正确的结构是让主内容和抽屉成为兄弟节点:
<body>
<div id="main">主内容</div>
<aside id="drawer">抽屉</aside>
</body>
然后 main.inert = true。这种结构比较清晰,也避开了上面那个坑。
坑二:动画和 inert 的时序。打开抽屉时如果先设置 main.inert = true,主内容的动画会立刻停止(因为 inert 影响渲染层的一些优化路径)。反之,如果先跑动画再设 inert,中间那段时间焦点仍然可以跑到主内容里。最终方案是”先设 inert,动画用 GPU 加速的 transform 属性”,配合 CSS transition 的时间来对用户感知做处理。这个不是 bug,是设计上的取舍。
坑三:inert 不影响 CSS。一个元素带 inert 属性,它的 CSS 样式不会被改变。它仍然占位、仍然渲染、仍然能被选择。inert 只影响交互和无障碍树,不影响视觉。所以想让被冻结的内容变暗,还是得自己加样式:
[inert] {
/* 可以在 CSS 里做样式区分,但要注意 inert 是布尔属性 */
opacity: 0.5;
}
用这个属性选择器的时候注意,只要 inert 存在就能选中(不管值是什么)。也可以用 [inert] 或者 :has([inert]) 做更细的样式。
坑四:在某些浏览器里,inert 元素的 iframe 仍会被加载。我们项目里有一个第三方客服 iframe,主内容加 inert 后,屏幕阅读器不读了,Tab 也跳不进去了,但 iframe 里的 JS 还在跑,还会往后台发请求。所以 inert 不等于”暂停”,只是”不可交互”。要真正暂停,得自己控制 iframe 的展示或者卸载。
坑五:异步加载内容时要注意。有些页面在 inert 状态时还在后台 fetch 数据,然后更新 DOM。这时候更新不会报错,但用户看不到(因为屏幕阅读器不读)。等 inert 解除的时候,看到的内容可能已经和初始状态对不上了,容易造成认知跳跃。我的建议是:主内容被 inert 的时候,尽量暂停次要的数据更新,让状态变化集中到解除冻结之后。
坑六:focus() 在 inert 状态下会静默失败。想要在关闭抽屉之前先把焦点还给”打开按钮”,那个按钮如果在 main 里并且 main 还是 inert 状态,button.focus() 调用不会报错,但焦点不会真的移过去。必须先把 main.inert = false,再 focus:
// 错误
triggerButton.focus() // 焦点不动
main.inert = false
// 正确
main.inert = false
triggerButton.focus() // 焦点挪过去了
这个坑比较隐蔽,因为调用 focus() 本身不会抛异常,只是”没效果”。第一次遇到的时候,我查了两个小时。
一个完整的可复用组件
经过多轮打磨,我们把抽屉重写成了一个简单的 Web Component,代码不到 100 行:
class SideDrawer extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<div class="mask" part="mask"></div>
<div class="panel" part="panel" role="dialog" aria-modal="true">
<slot></slot>
</div>
`
const mask = this.shadowRoot.querySelector('.mask')
mask.addEventListener('click', () => this.close())
}
get main() {
return document.getElementById(this.getAttribute('inert-target') || 'main')
}
open() {
this.setAttribute('open', '')
this.main.inert = true
const first = this.querySelector('button, a[href], input, [tabindex]:not([tabindex="-1"])')
first?.focus()
}
close() {
this.removeAttribute('open')
this.main.inert = false
document.getElementById(this.getAttribute('trigger'))?.focus()
}
}
customElements.define('side-drawer', SideDrawer)
用的时候:
<button id="openDrawerBtn" onclick="document.querySelector('side-drawer').open()">
打开抽屉
</button>
<side-drawer inert-target="main" trigger="openDrawerBtn">
<h2>设置</h2>
<button onclick="document.querySelector('side-drawer').close()">关闭</button>
</side-drawer>
这套组件替换掉了原来那个 800 多行的老实现,业务侧代码更简单,无障碍问题是真的一劳永逸。
什么时候不用 inert
inert 是很好,但不是所有场景都该用它。以下情况建议不用。
- 模态框里嵌套模态框时不用同一个 inert。每一层独立管理自己的 inert 目标,避免状态冲突。
- 需要动画暂停的不用 inert。正如前面第四坑说的,inert 不等于暂停。要真暂停动画/视频/定时器,得自己控制。
- 纯视觉隐藏用 hidden 属性或 CSS display: none。inert 的元素仍然占位、仍然渲染,只是不可交互。想要彻底隐藏,用别的方式。
它的定位很明确:做”让这块区域的用户交互暂时失效”这一件事。定位清晰之后用起来会很顺。
写在最后
这次抽屉重构给我最大的感受是:很多前端”必须手写”的逻辑,其实浏览器已经帮你实现了,只是我们习惯了过去的做法没有回头看。
焦点陷阱这个问题,从 jQuery 时代流行到现在,中间诞生了无数库和方案,每个人都被教育成”这是个复杂的问题”。但当浏览器把 inert 属性开放出来之后,整个问题的复杂度降到几乎为零。剩下的只是让开发者知道有这么个东西。
如果你手上也有类似的”必须用库才能做”的场景,比如模态框、抽屉、下拉菜单、屏幕阅读器管理,建议花点时间搜一下 HTML 最近的更新。可能会发现自己维护了好几年的东西,其实早就有原生方案了。
说起来容易,做起来需要持续关注新规范。但每次发现一个这样的”平替”,省下来的代码量和维护成本都是实打实的。

