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篇。每篇拆解一个前端组件的核心原理与实现细节。
Top comments (0)