前端有块代码,几乎每个项目都写过,写完还得改好几轮——焦点陷阱。抽屉打开的时候,按 Tab 键不能跑到后面的页面上去;侧栏浮层出现的时候,后面的按钮不能被点到;轮播翻页的时候,屏幕外的那些链接不能被键盘聚焦。
传统做法是在 keydown 里拦截 Tab,算出当前容器里所有可聚焦元素,到头了就手动转到第一个。这套逻辑写起来一百行左右,边界情况却多得离谱:动态插入的元素、tabindex="-1" 的特殊节点、iframe、Shadow DOM 里的内容、还有那个永远处理不干净的”Shift+Tab 反向环绕”。
其实浏览器早就提供了答案:inert。它做的事情只有一句——让这个子树不许被交互。不用算焦点顺序,不用拦键盘事件,四件事浏览器一次性帮你办好。
一、inert 到底关掉了什么
给一个元素加上 inert,它和它的所有后代会被:
- 移出焦点顺序。Tab 跳不过去,脚本调
el.focus()也不生效。 - 屏蔽指针交互。点击、触摸、拖拽都不再触发到里面的元素。
- 从无障碍树里摘掉。读屏软件会完全忽略这部分内容,连读都不读。
- 禁止编辑。里面原本可编辑的区域(
contenteditable、表单输入)无法获得光标。
它不做的也很明确:元素照常渲染、照常占位、照常参与布局。视觉上一点变化都没有——除非你自己写样式。
这个”不改变显示”的性质非常关键。它意味着 inert 可以用于那些必须留在页面上、但此刻不该被操作的区域,而不是像 display: none 或者 hidden 那样直接抹掉。
属性写法很简单,就是个布尔属性:
<main id="app">
<!-- 这里是页面主体 -->
</main>
<aside id="drawer">
<!-- 抽屉内容 -->
</aside>
const app = document.getElementById('app');
function openDrawer() {
app.inert = true;
}
function closeDrawer() {
app.inert = false;
}
用 JS 属性赋值和用 HTML 属性写 <main inert> 是一回事,前者会同步到 attribute 上,CSS 里也能用 [inert] 选中。
二、案例一:抽屉导航,焦点陷阱不用写了
先做最典型的。HTML 结构:
<header>
<button id="openBtn" type="button" aria-expanded="false" aria-controls="drawer">菜单</button>
</header>
<main id="app">
<h1>订单列表</h1>
<!-- 页面主体,很长 -->
</main>
<aside id="drawer" hidden>
<nav aria-label="主导航">
<ul>
<li><a href="/orders" rel="external nofollow" >订单</a></li>
<li><a href="/customers" rel="external nofollow" >客户</a></li>
<li><a href="/settings" rel="external nofollow" >设置</a></li>
</ul>
</nav>
<button id="closeBtn" type="button">关闭</button>
</aside>
和 inert 配合的脚本,重点不在开关,在焦点怎么进出:
const app = document.getElementById('app');
const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('openBtn');
const closeBtn = document.getElementById('closeBtn');
let lastFocused = null;
function openDrawer() {
lastFocused = document.activeElement;
drawer.hidden = false;
app.inert = true; // 主体冻结
document.body.classList.add('no-scroll');
openBtn.setAttribute('aria-expanded', 'true');
closeBtn.focus(); // 焦点送进抽屉
}
function closeDrawer() {
drawer.hidden = true;
app.inert = false;
document.body.classList.remove('no-scroll');
openBtn.setAttribute('aria-expanded', 'false');
lastFocused?.focus(); // 焦点送回原处
}
三件事分开说。
焦点要主动送进去。 app.inert = true 只是把主体冻住,抽屉本身并不会自动获得焦点。用户按 Tab 之后焦点会从 body 开始找下一个可聚焦元素,落到哪儿取决于 DOM 顺序,很可能直接跳出去。所以在打开之后立刻 closeBtn.focus(),把这个起点定下来。这也是为什么抽屉里最好有个明确的关闭按钮——它是最自然的初始焦点位置。
焦点要还记得送回来。 lastFocused 存的是打开之前用户所在的元素。关闭之后还原,用户按 Tab 会从原来那个位置继续往下走,而不是回到页面最顶端重新找。这一步如果漏了,键盘用户的体验会非常糟——读屏场景下尤其明显,用户会以为自己被扔到了别的地方。
inert 不管滚动。 这是新手最常期待落空的地方:app.inert = true 之后,主体确实点不动了,但滚轮照样能把背后的页面滚下去。inert 只处理交互和焦点,滚动是另一回事,还是得自己加 overflow: hidden(顺便配上 scrollbar-gutter 或者补个 padding,避免布局跳一下)。
至于焦点陷阱——不用写。主体 inert 之后,整个页面里可 Tab 到的元素只剩抽屉里那几个,Tab 到头会自然回到浏览器地址栏(这是符合预期的标准行为,别去拦它)。这才是把焦点陷阱交给浏览器之后最舒服的地方:不用再跟 getFocusableElements() 那套工具函数纠缠了。
顺手补个细节:点遮罩关闭
抽屉外面那层遮罩通常是独立元素,它自己不能 inert(不然就点不到了):
overlay.addEventListener('click', closeDrawer);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !drawer.hidden) closeDrawer();
});
注意 Escape 的监听放在 document 上而不是抽屉上,这样焦点万一飘出去也还能拦住。
三、案例二:轮播里那些看不见的页
轮播的问题很隐蔽:屏幕外的那几页并没有被隐藏,只是被 translate 挪出了视口。用 Tab 键一路按过去,焦点会跑到你看不见的地方,然后页面似乎卡住了——实际上焦点在视口外的那张卡片上。
结构大概是这样:
<section class="carousel" aria-roledescription="轮播">
<div class="track">
<article class="slide">第 1 页 <a href="/a" rel="external nofollow" >详情</a></article>
<article class="slide">第 2 页 <a href="/b" rel="external nofollow" >详情</a></article>
<article class="slide">第 3 页 <a href="/c" rel="external nofollow" >详情</a></article>
</div>
<button class="prev" type="button">上一页</button>
<button class="next" type="button">下一页</button>
</section>
let current = 0;
const slides = [...document.querySelectorAll('.slide')];
function goTo(index) {
current = Math.max(0, Math.min(index, slides.length - 1));
slides.forEach((slide, i) => {
slide.inert = i !== current; // 只有当前页可交互
slide.setAttribute('aria-hidden', i !== current ? 'true' : 'false');
});
track.style.setProperty('--index', current);
}
为什么用 inert 而不是给非当前页加 hidden?因为轮播要滑动。hidden 会让元素退出布局,滑动动画直接没了,最后一帧的位置全乱。inert 保持布局、保持渲染,只把交互和焦点关掉,正好是这里需要的语义。
这一行 aria-hidden 是配合用的,不是重复劳动:inert 会把子树从无障碍树里摘掉,理论上已经够了;但部分读屏软件在处理”视觉上存在、但不在当前页”的场景时,aria-hidden 能给出更明确的意图信号。两个一起写成本极低,稳一点没坏处。
有一个边界要留意:如果当前页里有元素正在获得焦点,切换页的时候要先处理焦点。 用户点了第 1 页里的链接,然后点”下一页”,那个链接立刻变成 inert——焦点会掉回 body。一种处理方式是切换后把焦点放到新页的第一个可聚焦元素,或者放到轮播容器本身(给容器加 tabindex="-1")。不处理也不会报错,但键盘用户会突然”失联”。
四、案例三:保存中的编辑器
这个场景比较特别:用户正在一个富文本区里编辑,点了保存,请求发出去了,这时候应该禁止继续输入——否则用户改的内容和发出去的内容对不上。
常见做法是加个 pointer-events: none 加半透明遮罩,结果键盘能继续打字;或者整个换成 contenteditable="false",但这会丢掉光标位置和选区,恢复的时候体验很跳。
inert 的做法:
<div id="editorWrap">
<div id="editor" contenteditable="true">...</div>
<button id="saveBtn" type="button">保存</button>
</div>
async function save() {
const wrap = document.getElementById('editorWrap');
wrap.inert = true;
try {
await fetch('/api/save', { method: 'POST', body: getContent() });
} finally {
wrap.inert = false;
}
}
核心就两行。编辑器里的 DOM 一点没动,光标位置、选区、滚动位置全都保留着,只是这段时间内它不接受任何输入。请求结束后解除,用户接着打,光标还在原地。
这里必须点明一个容易误解的地方:inert 不等于 disabled。 被 inert 冻住的输入框,value 仍然是可读的,脚本仍然能改,而且如果这个表单被提交了,它的值照样会被提交上去。如果你希望字段”像被禁用一样排除在提交之外”,那得用 disabled 或者 readonly,inert 不是干这个的。
反过来说,如果你希望数据还在、只是不让用户动,那 inert 恰好是对的——用 disabled 反而会丢值(取决于后端怎么处理)。
五、把边界划清楚:inert 和它的几个近亲
这几组概念经常被混着用,效果差很多:
inert vs aria-hidden
aria-hidden="true" 只管无障碍树,不管焦点也不管点击。用它藏内容,键盘用户照样能 Tab 进去、点击照样生效——这就是那个经典的”给装饰图标加 aria-hidden,结果里面的按钮还能点”的事故。inert 是”另一个方向的完整版”:管得全面,只管交互层面,不管是不是在无障碍树里有一席之地(它直接摘掉)。
需要纯视觉隐藏但保留可访问性的场景(比如只给读屏用的文字),用的是另一套裁剪技术,跟这两个都不是一回事。
inert vs disabled
disabled 只能用在表单控件上,作用范围就那一个元素,而且会让值不参与提交。inert 能用在任何元素上,作用于整个子树,不影响值。一个是控件状态,一个是交互冻结,别混。
inert vs dialog 的 showModal
这条容易出”白干一场”的事故:<dialog> 调 showModal() 之后,浏览器已经自动把背景内容置为 inert 了。这时候如果你再手动给别人加 inert、然后在关闭时手动移除,很容易漏掉一层,导致关闭之后页面整体卡死——所有按钮都点不动,看起来像 JS 挂了。
规则很简单:用模态 dialog 的地方不要再手动 inert。只有在你自己实现浮层(没用 dialog 也没用 popover)时,才需要手工上 inert。同样地,Popover API 在显示 popover 的”自动”模式下也会处理背景的交互,别叠着用。
六、五个绕不开的坑
1. 子树里没法”豁免”
inert 是从祖先往下整体生效的,子元素里写 inert = false 没有任何效果。也就是说,一旦某个容器被冻结,里面不可能单独放开某个按钮。
如果真有这种需求(比如”整体冻结,但保留一个’取消’按钮可点”),唯一办法是把这个按钮挪到容器外面去,而不是想办法让 inert 开口子。这不是限制,是提醒你把结构重新想一遍。
2. 设 inert 时焦点会掉到 body
如果当前焦点在要被冻结的子树里,el.inert = true 之后浏览器会把焦点移走,通常是 body。如果那一刻你在做一些依赖 document.activeElement 的事(比如记录”上次焦点”),必须在设 inert 之前读取,之后就晚了。
3. 框架里 inert="false" 仍然是 inert
这是布尔属性的经典陷阱。HTML 里 inert="false" 的值是字符串 "false",只要属性存在,元素就是 inert——它和 inert、inert=""、inert="任意文本" 效果完全一样。
React 19 之后对布尔属性做了正确处理,inert={false} 会真的移除属性;但旧版本以及一些模板引擎(包括某些服务端模板)会把这个值原样输出成 inert="false",于是”我明明传了 false”却依然冻住。碰到这种诡异现象,先去审查元素里看一眼属性到底长什么样。
最稳的写法是控制属性在不在,而不是控制它的值:
// 好
el.toggleAttribute('inert', shouldInert);
// 有风险(某些模板/框架生成 inert="false")
el.setAttribute('inert', String(shouldInert));
4. 别手工补 pointer-events
网上不少文章会在 [inert] 上再写一条 pointer-events: none,理由是”保险”。实际上浏览器已经处理了指针事件,重复加反而可能带来副作用:某些实现在 pointer-events: none 下会让滚动行为发生变化,触摸设备上尤其容易出现”滚不动”的怪异感。不要画蛇添足。
想要视觉反馈(变灰、变淡)是另一回事,那用 [inert] { opacity: ... } 或加个透明度过渡,属于纯装饰,随便写。
5. inert 不会让元素”消失”
inert 的内容仍然能被 Ctrl+F 搜索到,仍然占着布局,也仍然会被截图工具和导出工具抓到。如果你的诉求是”让这段内容彻底不可见”,那得用 hidden、display: none,或者在需要保留 DOM 但参与无渲染的场景用 content-visibility。
反过来说,这也正是 inert 在”浮层打开、背景要保持视觉连续”时的价值所在——你不能用 hidden,得让它留在那儿。
七、降级:现在还需要兜底吗
inert 的支持已经相当普遍,Chrome 102、Firefox 112、Safari 15.5 之后就都跟上了,现在再写 2024 年那种”必须 polyfill”的结论已经不合适。上线前查一下目标环境的实际支持矩阵就够了。
如果确实还得照顾非常老的设备,兜底不必做成完整的 inert 模拟(那套方案复杂度很高,还要处理 MutationObserver 监听动态插入的元素),更实用的做法是:至少把焦点这一层兜住。
if (!('inert' in HTMLElement.prototype)) {
// 降级:只在浮层打开时拦截 Tab,把焦点限制在容器内
document.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const layer = document.querySelector('.overlay:not([hidden])');
if (!layer) return;
const focusables = layer.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
}
代码只在新浏览器里不执行——if 那层保证了这点。它不完整(管不了点击穿透),但保住了键盘可用性这条底线。至于点击,老设备上就靠遮罩层盖住来挡吧,那个做法一直有效。
八、什么时候还是别用 inert
inert 很省事,但它不是”隐藏”和”禁用”的通用替代品。以下场景我通常不上它:
- 内容要根据状态长期消失。 那就用
hidden,语义更准,还能省掉渲染开销。inert 保留渲染是有代价的。 - 只是想让一个输入框暂时不能改。
readonly更轻,而且用户仍然能选中文本、复制、Tab 进去读一遍。inert 会连聚焦都不给,反而更糟。 - 模态 dialog 场景。 浏览器已经帮你 inert 背景了,再加一层只会制造 bug。
- 需要在子树里保留可交互的元素。 前面说过了,inert 没有豁免机制,结构不对就得改结构,别在 CSS 或 JS 里绕。
最后说个观察。inert 的价值不只在”少写代码”——它把一件原本由业务逻辑维护的事(哪些元素此刻能操作)变成了一个由 DOM 结构直接表达的事实。你打开 DevTools 看一眼,哪个子树被冻着,一目了然。手写的焦点陷阱做不到这点,它的状态藏在某个变量里,只有读代码的人知道。
这也算是近几年 HTML 的一个趋势:能交给标记表达的语义,就别让它散落在脚本里。inert 是个很好的例子,它足够小,小到很多人觉得”没必要专门学”,但真用起来,省掉的代码和绕开的事故都挺实在。

