Building an accessible drawer in 200 lines
A drawer is a modal dialog with a layout opinion. Treat semantics, focus, escape, scroll lock, and QA as the core work.
A drawer looks like a layout component, but an accessible drawer is mostly behavior. It opens above the page, traps focus, closes predictably, restores focus, blocks background scroll, and adapts when the viewport gets small.
I treat a modal drawer as a dialog with a placement opinion. The fact that it slides from the side does not change the semantics. If the rest of the page is unavailable while the drawer is open, the drawer should behave like a modal dialog.
Start with dialog semantics
Native dialog gives you a strong baseline: modal behavior, Escape handling through the cancel event, focus behavior, and a top-layer rendering model. You still need to make careful choices, but you are not starting from a div with ambition.
<button type="button" data-drawer-open="cart-drawer">
Open cart
</button>
<dialog class="drawer" id="cart-drawer" aria-labelledby="cart-title">
<form method="dialog" class="drawer__panel">
<header class="drawer__header">
<h2 id="cart-title">Cart</h2>
<button type="submit" aria-label="Close cart">Close</button>
</header>
<div class="drawer__body">
<!-- Drawer content -->
</div>
</form>
</dialog>
The heading gives the dialog a name. The close button is a real button. The form method lets the close button dismiss the dialog without custom code, although I often still centralize close behavior to restore focus and clean up scroll lock.
Layout is the easy part
The drawer shell can be straightforward.
.drawer {
width: min(420px, 100vw);
max-width: none;
height: 100dvh;
max-height: none;
margin: 0 0 0 auto;
padding: 0;
border: 0;
background: transparent;
}
.drawer::backdrop {
background: rgb(0 0 0 / 0.36);
}
.drawer__panel {
min-height: 100%;
display: grid;
grid-template-rows: auto 1fr auto;
background: white;
box-shadow: -24px 0 64px rgb(0 0 0 / 0.18);
}
@media (max-width: 560px) {
.drawer {
width: 100vw;
}
}
Animation should respect reduced motion. A drawer slide can be nice, but it is not required for comprehension.
Focus trap and focus return
Native modal dialog prevents focus from leaving the dialog in modern browsers, but I still test focus aggressively and keep a small helper for initial focus and return focus.
When the drawer opens:
- Store the element that opened it.
- Show the modal dialog.
- Move focus to the first meaningful control or the dialog heading.
When it closes:
- Release scroll lock.
- Return focus to the opener if it still exists.
let lastFocused: HTMLElement | null = null;
function openDrawer(dialog: HTMLDialogElement) {
lastFocused = document.activeElement as HTMLElement;
lockScroll();
dialog.showModal();
const target = dialog.querySelector<HTMLElement>(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
);
target?.focus();
}
function closeDrawer(dialog: HTMLDialogElement) {
dialog.close();
unlockScroll();
lastFocused?.focus();
lastFocused = null;
}
If your support matrix requires a custom focus trap, keep it small and test it with Shift+Tab from the first focusable element and Tab from the last. Focus bugs are easiest to miss with a mouse.
Escape and overlay close
Escape should close the drawer unless the drawer contains a nested interaction that has a stronger reason to own Escape. Listen for the dialog cancel event if you need cleanup.
Overlay click is useful, but it needs precision. Close only when the click starts on the dialog backdrop, not when the user clicks inside the panel.
dialog.addEventListener("cancel", () => {
unlockScroll();
lastFocused?.focus();
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
closeDrawer(dialog);
}
});
For destructive or multi-step drawers, I may disable overlay close and require explicit buttons. The rule is based on consequence. A cart drawer can usually close on overlay. A permissions editor with unsaved changes needs more care.
Scroll lock
Scroll lock is small until it breaks the page. The background should not scroll while the drawer is modal. The drawer body should scroll internally when content is long.
let scrollY = 0;
function lockScroll() {
scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = "-" + scrollY + "px";
document.body.style.width = "100%";
}
function unlockScroll() {
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
window.scrollTo(0, scrollY);
}
On mobile browsers, test with the address bar changing size and with form fields focused. Drawers that look correct on desktop can become awkward when the software keyboard appears.
Responsive behavior
A side drawer on desktop often wants to become a full-screen sheet on mobile. That is fine as long as the behavior stays the same: named dialog, visible close action, trapped focus, Escape where available, scroll contained inside the drawer, and focus return.
Do not shrink a complex desktop drawer into a cramped mobile panel just because the component is called a drawer. If the task needs space, use the full screen.
QA checklist
Before I call a drawer done, I check:
- Can I open it with keyboard only?
- Does focus move into it?
- Does Tab stay inside it?
- Does Escape close it?
- Does the close button have an accessible name?
- Does focus return to the opener?
- Does background scroll stop?
- Can long drawer content scroll?
- Does it work at mobile widths with the keyboard open?
- Does reduced motion remove nonessential animation?
- Is the drawer title announced by screen readers?
That checklist is the component. The slide-in layout is just the visible part.