移动端抽屉面板是个几乎每个项目都会碰到的组件。点汉堡按钮,面板从左侧滑出,同时页面其余部分要「冻住」——不能再点、不能再滚、键盘 Tab 不能跑进去。这个需求本身不复杂,就是「禁用整块 UI」。
过去十年里,最常见的做法是扔一层半透明的遮罩上去,给它加上 pointer-events: none 或者干脆就用一个盖满屏幕的 div 挡住点击。做法简单,也基本能用。但只要碰到键盘用户或者屏幕阅读器用户,问题就冒出来了:鼠标确实点不到背后的东西了,可 Tab 键照样能跳过去,屏幕阅读器照样能念出背后的内容。这不是「禁用」,这只是「挡住鼠标」而已。
还有一个更隐蔽的问题。iOS 和安卓上,遮罩挡住了点击但挡不住页面的滚动穿透。用户以为面板开着的时候在滚面板,实际上滚的是背后的页面——那种「页面跟着滑动、内容对不上」的诡异体验,很多移动端的 bug 报告都源于此。
HTML 里有一个属性正好干这件事:inert。这篇文章从它到底做了什么讲起,然后用三个真实场景把它用一遍,最后把几个容易踩的坑摊开说。
一、inert 到底做了什么
给一个元素加上 inert,浏览器会同时做三件事:
第一,元素和它的所有后代都不能获得焦点。不管是 tab 键、element.focus(),还是 autofocus 属性,全部失效。这一点和 disabled 有点像,但 disabled 只对单个表单控件有效,inert 管的是整棵子树。
第二,元素和它的所有后代不响应任何指针事件。鼠标点击、触摸滑动、PointerEvent,全都穿透到下面。这一点和 pointer-events: none 效果类似,区别在于——pointer-events: none 只影响指针,不影响键盘。而 inert 是两样都不响应。
第三,元素和它的所有后代从无障碍树里移除。屏幕阅读器完全看不到它们,就像这些内容根本不存在一样。语义上等价于 aria-hidden="true",但 aria-hidden 只管屏幕阅读器,不管交互;inert 两样都管。
把这三件事合起来想,inert 的含义就很清楚了——它表示「这个区域暂时不存在,请当作没有这块内容」。这正好就是抽屉面板打开时对页面背景的诉求。不是「藏起来」,也不是「挡住」,而是「暂时视而不见」。
有个地方必须提前说清楚:inert 不会改变任何视觉呈现。元素还是照常显示、照常占据空间、照常参与布局,只是不再响应交互。所以如果你希望背景看起来「变暗了」,还得自己加一层半透明的遮罩或者改 opacity。inert 和遮罩是两件事,可以一起用,也可以分开用。
二、三种写法,哪种最合适
把它打开有三种方式。
HTML 里直接写:
<main inert>
<p>这部分暂时不需要交互</p>
</main>
JS 里切换属性:
main.setAttribute('inert', '');
main.removeAttribute('inert');
用 IDL 属性:
main.inert = true;
main.inert = false;
三种写法都会生效,但日常推荐第二种还是第三种?我的经验是 用 IDL 属性(.inert = true),跟 .hidden、.checked 是同一个思路。原因有两个。
一是布尔语义更清楚。setAttribute('inert', 'false') 其实是打开 inert 的——因为布尔属性只要存在就生效,管你写的什么值。这个坑我第一次用的时候就踩到了,调试了半小时才想起来 inert="false" 并不等于「关闭 inert」。
二是读的时候更直观。if (main.inert) 一看就懂,if (main.hasAttribute('inert')) 稍微啰嗦一点。
三、案例一:移动端抽屉面板
HTML 结构大概是这样的:
<nav id="drawer">
<a href="/" rel="external nofollow" >首页</a>
<a href="/orders" rel="external nofollow" >订单</a>
<a href="/settings" rel="external nofollow" >设置</a>
</nav>
<main id="page">
<button id="open-drawer" type="button">菜单</button>
<p>页面里的内容......</p>
</main>
打开抽屉的逻辑:
const drawer = document.getElementById('drawer');
const page = document.getElementById('page');
const openBtn = document.getElementById('open-drawer');
function openDrawer() {
drawer.hidden = false;
page.inert = true;
drawer.querySelector('a').focus();
}
function closeDrawer() {
drawer.hidden = true;
page.inert = false;
openBtn.focus();
}
三行就完事了。但有三处小细节值得掰开看。
第一行里的 drawer.hidden = false 是有意为之。抽屉关闭的时候需要真的隐藏起来,不然它的内容也会出现在无障碍树里。这里用 hidden 而不是 display: none 是习惯问题,效果基本一致,但 hidden 语义更清楚。
第二行是关键。page.inert = true。页面主内容整块被冻结。鼠标点不进去,键盘 Tab 跳不进去,屏幕阅读器念到这里就跳过。过去要写好几层监听和手动管理 tabindex 的那一堆事,现在一个属性搞定。
第三行是焦点管理。抽屉打开后 drawer.querySelector('a').focus() 把焦点移到抽屉里的第一个链接上,键盘用户才能立即开始导航。关闭时 openBtn.focus() 把焦点还回去,用户按 Tab 会从菜单按钮继续,不会跳到页面顶部。
关于焦点归还这一条,多写一句。浏览器其实会对 inert 做一些自动处理——如果一个持有焦点的元素变成 inert,浏览器会把焦点移走。但移到哪里是不确定的,通常落到 body 上。对于键盘用户来说,焦点掉到 body 意味着下一次 Tab 从页面的第一个可聚焦元素开始,体验很差。所以手动设置焦点落点,是 inert 场景下必须做的一步。
关于 iOS 上的滚动穿透
前面提过,仅靠遮罩层挡不住 iOS 的滚动穿透。加上 page.inert = true 之后,页面主内容区域的触摸事件不再被接收,滚动就不会传递过去。不过这里有个边界:如果 body 或者 html 上还有滚动,那穿透可能仍然发生。稳妥的做法是在 openDrawer 里额外加一句 document.body.style.overflow = 'hidden',closeDrawer 时再恢复。这个是 CSS 层面的补充,和 inert 不冲突。
四、案例二:多步表单的当前步骤
有个结算流程分了四步:填写地址、选择配送、确认支付、结果。用单页应用把它做成一个上下滚动的长表单是最省事的,但 UX 上不好——用户会迷失在字段堆里。所以通常会做成「一次只显示一步」,其他步骤藏起来。
藏的方式有两种:display: none 或者 inert。如果用 display: none,被隐藏的字段不会进入 DOM 布局,也就是在提交的时候会被忽略掉。有些表单库依赖「所有字段都挂载」这一点来做统一的校验和提交,这时候直接隐藏就会出问题。
用 inert 就很好。步骤容器保留在 DOM 里,字段还在,只是不能交互。切换的时候只改 inert 状态:
const steps = document.querySelectorAll('.step');
function goTo(stepIndex) {
steps.forEach((step, i) => {
const isCurrent = (i === stepIndex);
step.inert = !isCurrent;
step.setAttribute('aria-current', isCurrent ? 'step' : 'false');
});
}
这么做还有个额外的好处。inert 会把非当前步骤从无障碍树里移除,屏幕阅读器用户切到某一步的时候,读屏只会念出当前这一块,不会被前面三步的内容淹没。如果用 visual-hidden 之类的 CSS 技巧「视觉隐藏但屏幕阅读器可见」,那读屏用户就会读一大堆用不上的内容。
当然,如果这些非当前步骤的内容会很大(比如每一步都有几十个字段),长期挂在 DOM 里会有点浪费。这时可以折中:当前一步和下两步用 inert 保留,更远的用 display: none 完全移除,用户往回走的时候再恢复。这是性能和交互的一个权衡,具体留多少看数据量。
五、案例三:标签页的非激活面板
标签页组件有一个类似的需求。传统实现是把非激活面板 display: none 掉,或者用 visibility: hidden 藏起来。
display: none 的问题是所有面板的布局都会在切换的时候重新计算。如果某个面板里有一个很大的图表或者媒体元素,切换的时候会有明显的卡顿。而且 display: none 会让内部的 iframe 重新加载,<video> 停止播放位置丢失。
用 inert 至少解决了「内容需要保留在 DOM 里」这一点:
function activateTab(panelId) {
panels.forEach(panel => {
const isActive = panel.id === panelId;
panel.inert = !isActive;
panel.setAttribute('aria-hidden', String(!isActive));
panel.hidden = !isActive; // 视觉上还是要藏
});
}
等等,这里同时用了 inert 和 hidden,会不会重复?其实不会。hidden 负责视觉隐藏——非激活标签页的面板本来就不应该显示。inert 在这里的作用是「兜底」——如果哪天 hidden 被别的样式覆盖了(比如某处写了一个 [hidden] { display: block !important } 的处理),inert 仍然保证非激活面板不会被键盘和鼠标碰到。
这个「双保险」的模式在复杂组件里挺常见的。视觉隐藏和交互隐藏是两件事,各自都要有保障。
六、焦点被 inert 掉的时候会发生什么
有个场景必须单独讲:如果当前持有焦点的元素突然被设成 inert,浏览器会把焦点移走。移到哪里呢?规范说是「紧邻的可聚焦祖先」,具体实现上有差异,通常落到 body。
const input = document.getElementById('email');
input.focus(); // 假设焦点在输入框上
input.closest('form').inert = true;
// 现在焦点已经不在输入框上了,通常落在 body
document.activeElement; // body 或者 html
问题在于,焦点从输入框掉到 body 之后,用户的下一次 Tab 会从页面第一个可聚焦元素开始。如果他的意图是继续操作弹出来的对话框,这个体验非常差。
解决办法是在失去焦点之前主动把焦点移走。不要等 inert 生效之后才发现焦点没了:
function openModal() {
lastFocused = document.activeElement; // 记下当前焦点
modal.hidden = false;
page.inert = true;
modal.querySelector('button').focus(); // 主动移到弹窗里
}
function closeModal() {
modal.hidden = true;
page.inert = false;
lastFocused?.focus(); // 还给原来的元素
}
这个「记录焦点、主动移动、恢复焦点」的三步,是任何会改变可聚焦区域的交互都该做的。inert 只是让这件事更容易做得不彻底,因为它会让焦点「悄悄消失」,不像 focus() 失败那样会立即报错。
七、几个实际会踩的坑
1. inert 不会阻止正在运行的 JS
这一点很容易被误以为。给一个 <video> 的父元素加 inert,视频不会暂停;给一个 setInterval 所在的区域加 inert,定时器照样跑。inert 管的是「用户能不能与这块内容交互」,不是「这块内容内部的代码跑不跑」。
如果希望 inert 的时候顺便停止某些行为,得自己写逻辑,比如在打开抽屉的时候给视频 pause(),或者在 inert 状态下让定时器检查一下 element.inert 再决定要不要继续。
2. inert 元素的子元素不能再设 inert=”false”
inert 是继承的。父元素被设为 inert 之后,所有后代都在 inert 区域内,没有一个「反 inert」的开关能让某个子元素单独恢复。
<div inert>
<p>被禁用了</p>
<button>点不了</button>
</div>
想恢复某个子元素,唯一的办法是用 <template> 或者 moveBefore(新的 DOM 方法)把它移出这个区域再插入回别的地方。当然这种场景本来就很少见,碰到的时候先想想结构上是不是可以调整。
3. inert 和 aria-hidden 一起用没问题
有时候开发者会担心「inert 是不是还要搭配 aria-hidden="true" 一起用才完整」。不需要。inert 已经包含了对无障碍树的处理,重复加 aria-hidden 不会出问题,但也完全没必要。反而要注意的是不要反过来写——只加 aria-hidden 而不加 inert,那只是给屏幕阅读器隐藏了内容,鼠标和键盘照样能用,这通常不是想要的效果。
4. 在 body 上设置 inert 会冻结整个页面
document.body.inert = true;
这行代码会让页面上所有内容都不能交互——包括弹窗本身。所以模态弹窗的正确写法是:把弹窗挂在 body 的直接子元素位置,然后给除了弹窗之外的所有兄弟节点设 inert,而不是直接给 body 设。当然如果是用原生的 <dialog> 配合 showModal(),浏览器会自动处理这一切,那就完全不用自己操心了——这也是为什么我强烈建议模态弹窗用 dialog 而不是自己糊一套。
5. 兼容性和检测
inert 目前主流浏览器都支持,但版本比较低的目标环境可能还没有。做一个特性检测很简单:
const supportsInert = 'inert' in HTMLElement.prototype;
function setInert(el, value) {
if (supportsInert) {
el.inert = value;
} else {
// 老环境下的降级
if (value) {
el.setAttribute('aria-hidden', 'true');
el.style.pointerEvents = 'none';
} else {
el.removeAttribute('aria-hidden');
el.style.pointerEvents = '';
}
}
}
降级方案只是权宜之计——它解决不了键盘 Tab 仍然会跳进去的问题。老实说,如果目标环境支持得很差,那干脆别依赖 inert,回到自己管理 tabindex 的老路上会更稳妥。这东西的收益本来就建立在「原生帮你做那些繁琐的事」上,降级到手工又失去了它的意义。
6. 不要用它假装「视觉隐藏」
有一个误解值得单独说。inert 元素仍然占据布局空间、仍然可见、仍然会影响页面高度。如果你想要的效果是「这块内容不能交互,同时看不见」,那得分别处理:inert 负责交互禁用,hidden 或者 display: none 负责视觉隐藏。
我见过一个真实的 bug,就是有人以为 inert 会自动隐藏内容,结果一个很长的草稿列表被设成 inert 之后依然占着半屏高度,用户以为界面坏了。
八、什么时候不用它
说完用法,也得说说什么时候不要用。
如果内容是真的不需要了,直接用 hidden 或者 display: none。inert 适合「暂时保留但暂时不可交互」的场景,如果这块内容根本就是不该出现的(比如一个已删除的列表项),那就应该把它从 DOM 里删掉或者完全隐藏。用 inert 保留下来只会让内存占用和渲染开销白白多出来。
单个表单控件的禁用,还是用 disabled。disabled 会让控件灰色化、会阻止表单提交时被包含进去,是一个更完整的语义。inert 用在整块区域上更合适。
模态弹窗优先用 <dialog>。原因前面说过了——showModal() 会自动把你需要的东西全做了:页面其余部分 inert、焦点锁定在弹窗内、Esc 关闭、::backdrop 伪元素。自己用 inert 手搓一套弹窗,要重建的东西比想象中多。
别用它来做「加载中禁用」。这个场景很常见:一个提交按钮被点了,页面在等待结果,开发想给整个表单加 inert 防止重复提交。可以,但别忘了这是暂时的、要记得恢复。而且用户其实更喜欢看到一个明确的「提交中……」状态,全表单变成点不动的状态会让人以为页面死机了。
九、写在最后
inert 这个属性真正改变的,不是少写了几行代码,而是把一个「假装禁用」的老套路换成了「真的禁用」。用 pointer-events: none 加遮罩的写法,本质上是在赌用户不会用键盘、不会开屏幕阅读器。这个赌注在以前的桌面浏览器时代可能还能赢,到现在移动端和辅助技术普及之后已经不太靠得住了。
更值得说的一个点是:inert 让「这块内容现在不参与交互」变成了一个可以声明的事实,而不是分散在多个元素上的临时状态。过去要实现「禁用整块区域」,你得循环遍历所有可聚焦元素,给它们调整 tabindex、加 aria-hidden、改 pointer-events。现在一个属性,收工。而且这个状态是被浏览器认同的——不是「看起来被禁用了」,而是「真的从无障碍树里被拿掉了」。
如果手头有个抽屉面板或者标签页组件还在用遮罩层的老写法,可以挑一个先改成 inert。改完之后拿键盘走一遍:Tab 键能不能跳出当前区域、关闭之后焦点落在哪里、屏幕阅读器有没有念出不相关的内容。这三个检查过了,剩下的同类场景就可以放心迁移了。

