侧边抽屉 | Component Deep Dive #38: Drawer — Focus Trap, Inert, and Body Scroll Lock
侧边抽屉 | Component Deep Dive #38: Drawer — Focus Trap, Inert, and Body Scroll Lock
点一个按钮,面板从侧边滑出。但用户按Tab键跑了,背景还在滚动——这不算抽屉,这是灾难。
侧边抽屉(Drawer / Off-canvas Panel)在导航、筛选、详情展示中广泛使用。面板从屏幕边缘滑入,覆盖部分内容或全部内容。视觉上很简单——transform: translateX()加个transition就行。但真正难的是焦点管理和滚动锁定。
核心原理
抽屉打开时需要做三件事:
- 锁定背景滚动 — 用户在抽屉内滚动时,背景页面不应跟着动
- 陷阱焦点 — Tab键只能在抽屉内部循环,不能跑到背景中去
- 恢复焦点 — 关闭抽屉后,焦点回到触发按钮
传统实现用overflow:hidden锁滚动,手动遍历focusable元素做焦点陷阱。现在有了inert属性——一行代码就能把背景内容从tab序列和辅助技术中完全移除。
实现
<div id="app">
<header>
<button id="open-drawer">打开筛选</button>
</header>
<main>
<p>页面主内容...</p>
</main>
</div>
<aside class="drawer" id="filter-drawer" hidden>
<div class="drawer-overlay" data-close></div>
<div class="drawer-panel" role="dialog"
aria-modal="true" aria-labelledby="drawer-title">
<header class="drawer-header">
<h2 id="drawer-title">筛选条件</h2>
<button class="drawer-close" data-close aria-label="关闭">x</button>
</header>
<div class="drawer-body">
<!-- 筛选内容 -->
<label><input type="checkbox"> 仅看有货</label>
<label><input type="checkbox"> 免运费</label>
<button>应用筛选</button>
</div>
</div>
</aside>
.drawer { position: fixed; inset: 0; z-index: 100; }
.drawer-overlay {
position: absolute; inset: 0;
background: rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 0.3s;
}
.drawer-panel {
position: absolute;
top: 0; right: 0; bottom: 0;
width: min(400px, 90vw);
background: #fff;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgba(0,0,0,0.1);
}
.drawer[open] .drawer-overlay { opacity: 1; }
.drawer[open] .drawer-panel { transform: translateX(0); }
const drawer = document.getElementById('filter-drawer');
const openBtn = document.getElementById('open-drawer');
const app = document.getElementById('app');
let lastFocused = null;
function getFocusable(container) {
return Array.from(container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
)).filter(el => el.offsetParent !== null);
}
function openDrawer() {
lastFocused = document.activeElement;
drawer.hidden = false;
// Force reflow for transition
drawer.offsetHeight;
drawer.setAttribute('open', '');
// Lock background: inert makes it unclickable, unfocusable,
// and invisible to screen readers
app.inert = true;
document.body.style.overflow = 'hidden';
// Focus first element in drawer
const focusable = getFocusable(drawer);
if (focusable.length) focusable[0].focus();
}
function closeDrawer() {
drawer.removeAttribute('open');
app.inert = false;
document.body.style.overflow = '';
// Wait for transition, then hide
setTimeout(() => {
drawer.hidden = true;
if (lastFocused) lastFocused.focus();
}, 300);
}
// Focus trap inside drawer
drawer.addEventListener('keydown', e => {
if (e.key !== 'Tab') return;
const focusable = getFocusable(drawer);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
// Close on overlay/click
drawer.addEventListener('click', e => {
if (e.target.hasAttribute('data-close')) closeDrawer();
});
// Close on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && drawer.hasAttribute('open')) {
closeDrawer();
}
});
openBtn.addEventListener('click', openDrawer);
inert属性
inert是HTML原生的”冻结”属性。设置inert的元素及其子树:
- 不可聚焦(已有的焦点被移除)
- 不可点击(鼠标和触摸事件被忽略)
- 对屏幕阅读器隐藏(aria-hidden效果)
- 不参与页面序列查找
这意味着不需要手动管理背景区域的aria-hidden和tabindex="-1"——一个属性全搞定。2024年起所有主流浏览器均已支持。
常见陷阱
-
transition与hidden冲突 —
hidden属性会立即移除元素,CSS transition来不及播放。解决方法:先移除hidden,强制reflow(el.offsetHeight),再添加open状态类。关闭时反向操作:先移除open,等transition结束后再设hidden。 -
iOS Safari的body滚动锁定 —
overflow:hidden在iOS Safari上不可靠,需要额外加position:fixed+记录滚动位置。或者用overscroll-behavior:contain限制抽屉内滚动不传播到body。 -
焦点跑到面板外面 — 没有
inert或焦点陷阱时,用户按Tab键会跑到背景页面的链接上。视觉上面板在前,但焦点在后面,屏幕阅读器用户完全困惑。 -
Esc键关闭后焦点丢失 — 关闭抽屉后焦点回到body开头,用户需要重新定位。正确做法是记录打开时的
document.activeElement,关闭后恢复。
底层逻辑
抽屉本质是一个”模态”组件——它创建了一个临时的交互层,用户必须先处理这个层才能回到主页面。模态的核心约束是:背景内容应该被”冻结”,而不是仅仅被”遮挡”。
inert属性完美实现了这个语义:它不是视觉遮挡,而是交互隔离。背景内容仍然可见(通过半透明遮罩),但不可操作。这比pointer-events:none更彻底——它还阻止了键盘导航和辅助技术访问。
焦点陷阱的实现逻辑:在抽屉内监听Tab键,当焦点到达第一个或最后一个focusable元素时”折返”。配合inert,即使折返逻辑有bug,用户也跑不到背景中去——双保险。
Drawer | Component Deep Dive #38: Drawer — Focus Trap, Inert, and Body Scroll Lock
Click a button, a panel slides in from the side. But the user presses Tab and escapes, the background keeps scrolling — that’s not a drawer, that’s a disaster.
The Drawer (Off-canvas Panel) is widely used in navigation, filtering, and detail views. A panel slides in from the screen edge, covering part or all of the content. Visually it’s simple — transform: translateX() with a transition. But the real challenge is focus management and scroll locking.
Core Principle
Three things must happen when a drawer opens:
- Lock background scroll — When the user scrolls inside the drawer, the background page shouldn’t move
- Trap focus — Tab key should cycle only within the drawer, not escape to the background
- Restore focus — After closing, focus returns to the trigger button
Traditional implementations use overflow:hidden for scroll locking and manually iterate focusable elements for focus trapping. Now we have the inert attribute — one line removes background content entirely from the tab sequence and assistive technology.
Implementation
<div id="app">
<header>
<button id="open-drawer">Open Filters</button>
</header>
<main>
<p>Main page content...</p>
</main>
</div>
<aside class="drawer" id="filter-drawer" hidden>
<div class="drawer-overlay" data-close></div>
<div class="drawer-panel" role="dialog"
aria-modal="true" aria-labelledby="drawer-title">
<header class="drawer-header">
<h2 id="drawer-title">Filter Options</h2>
<button class="drawer-close" data-close aria-label="Close">x</button>
</header>
<div class="drawer-body">
<label><input type="checkbox"> In stock only</label>
<label><input type="checkbox"> Free shipping</label>
<button>Apply Filters</button>
</div>
</div>
</aside>
.drawer { position: fixed; inset: 0; z-index: 100; }
.drawer-overlay {
position: absolute; inset: 0;
background: rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 0.3s;
}
.drawer-panel {
position: absolute;
top: 0; right: 0; bottom: 0;
width: min(400px, 90vw);
background: #fff;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgba(0,0,0,0.1);
}
.drawer[open] .drawer-overlay { opacity: 1; }
.drawer[open] .drawer-panel { transform: translateX(0); }
const drawer = document.getElementById('filter-drawer');
const openBtn = document.getElementById('open-drawer');
const app = document.getElementById('app');
let lastFocused = null;
function getFocusable(container) {
return Array.from(container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
)).filter(el => el.offsetParent !== null);
}
function openDrawer() {
lastFocused = document.activeElement;
drawer.hidden = false;
drawer.offsetHeight; // Force reflow
drawer.setAttribute('open', '');
app.inert = true;
document.body.style.overflow = 'hidden';
const focusable = getFocusable(drawer);
if (focusable.length) focusable[0].focus();
}
function closeDrawer() {
drawer.removeAttribute('open');
app.inert = false;
document.body.style.overflow = '';
setTimeout(() => {
drawer.hidden = true;
if (lastFocused) lastFocused.focus();
}, 300);
}
// Focus trap
drawer.addEventListener('keydown', e => {
if (e.key !== 'Tab') return;
const focusable = getFocusable(drawer);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
drawer.addEventListener('click', e => {
if (e.target.hasAttribute('data-close')) closeDrawer();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && drawer.hasAttribute('open')) closeDrawer();
});
openBtn.addEventListener('click', openDrawer);
The inert Attribute
inert is a native HTML “freeze” attribute. Elements with inert and their subtree:
- Cannot be focused (existing focus is removed)
- Cannot be clicked (mouse and touch events are ignored)
- Hidden from screen readers (aria-hidden effect)
- Excluded from page sequence finding
This means no manual aria-hidden or tabindex="-1" management for the background — one attribute handles everything. All major browsers have supported it since 2024.
Common Pitfalls
-
Transition vs hidden conflict — The
hiddenattribute removes the element immediately, before CSS transition can play. Solution: removehiddenfirst, force reflow (el.offsetHeight), then add theopenstate class. Reverse on close: removeopen, wait for transition to end, then sethidden. -
iOS Safari body scroll lock —
overflow:hiddenis unreliable on iOS Safari. You needposition:fixed+ scroll position restoration, oroverscroll-behavior:containto prevent scroll propagation. -
Focus escaping the panel — Without
inertor focus trap, Tab key sends focus to background page links. Visually the panel is in front, but focus is behind — screen reader users are completely lost. -
Focus lost after Esc close — After closing, focus lands at the body start. Correct approach: record
document.activeElementwhen opening, restore it when closing.
Underlying Logic
A drawer is essentially a “modal” component — it creates a temporary interaction layer that the user must deal with before returning to the main page. The core constraint: background content should be “frozen,” not merely “covered.”
The inert attribute perfectly implements this semantic: it’s not visual occlusion, it’s interaction isolation. Background content remains visible (through the semi-transparent overlay) but is not operable. This is more thorough than pointer-events:none — it also blocks keyboard navigation and assistive technology access.
Focus trap logic: listen for Tab key inside the drawer, and “fold back” when focus reaches the first or last focusable element. Combined with inert, even if the fold-back logic has a bug, the user can’t escape to the background — double insurance.
本文是「组件深度解析」系列第38篇。每篇拆解一个前端组件的核心原理与实现细节。