DEV Community

WDSEGA
WDSEGA

Posted on

Component Deep Dive #38: Drawer — Focus Trap, Inert, and Body Scroll Lock

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:

  1. Lock background scroll — When the user scrolls inside the drawer, the background page shouldn't move
  2. Trap focus — Tab key should cycle only within the drawer, not escape to the background
  3. 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>
Enter fullscreen mode Exit fullscreen mode
.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); }
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode

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

  1. Transition vs hidden conflict — The hidden attribute removes the element immediately, before CSS transition can play. Solution: remove hidden first, force reflow (el.offsetHeight), then add the open state class. Reverse on close: remove open, wait for transition to end, then set hidden.

  2. iOS Safari body scroll lockoverflow:hidden is unreliable on iOS Safari. You need position:fixed + scroll position restoration, or overscroll-behavior:contain to prevent scroll propagation.

  3. Focus escaping the panel — Without inert or 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.

  4. Focus lost after Esc close — After closing, focus lands at the body start. Correct approach: record document.activeElement when 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)