A drawer is one of those components you meet a hundred times a day and never think about: the mobile menu that slides in from the left, the cart that flies in from the right, the filter sheet that comes up from the bottom. It looks trivial — a panel that moves. But a drawer that behaves properly is really a modal dialog wearing a slide animation, and modals are where a surprising number of accessibility bugs live. For Day 31 of DesignFromZero I built one in vanilla JS, from any edge, and the interesting part was how much of the work happens off-screen and out of view.
You can play with the finished thing here: https://dev48v.infy.uk/design/day31-drawer.html
Park it off-canvas, slide it with transform
The panel starts fully built but out of sight. It is position: fixed, pinned to an edge, and pushed past that edge with a transform: a left drawer sits at left: 0 translated -100% on X, a bottom sheet sits at bottom: 0 translated 100% on Y. Nothing is hidden with display: none.
Opening is then a single class:
.drawer { transition: transform .32s cubic-bezier(.4,0,.2,1); will-change: transform; }
.drawer.open { transform: none; }
The reason to animate transform and not left (or toggle display) is twofold. display cannot be transitioned at all, so it pops. Animating left triggers layout on every frame. transform is compositor-only — the browser slides an already-painted layer on the GPU without re-running layout or paint — so it stays smooth at 60fps even on a phone.
The scrim does two jobs
Behind the panel sits a full-screen backdrop. It dims the page so the drawer reads as modal, and it is the click-outside target. Fade it with opacity, but also toggle visibility so it can't swallow clicks while it is fully transparent, and wire its click straight to close.
.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5);
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; z-index: 60; }
.scrim.open { opacity: 1; visibility: visible; }
The keyboard is where it gets real
Sliding the panel in with the mouse is the easy 20%. The other 80% is focus. When the drawer opens, three things have to happen:
- Remember the element that opened it, so focus can be restored later.
- Move focus into the panel (the first focusable control), so the very next Tab keeps the user inside.
- Trap focus so Tab can't leak to the page behind the scrim.
That last one is the classic bug. Without a trap, tabbing off the last control jumps to the links behind the drawer — still there, just visually covered — and a keyboard user is silently lost. The trap itself is tiny:
function onKeydown(e){
if (e.key !== "Tab") return;
const f = focusables(); // ordered list inside the drawer
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first){ e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last){ e.preventDefault(); first.focus(); }
}
Tab on the last element wraps to the first; Shift+Tab on the first wraps to the last. Everything in between behaves normally. On close, focus goes back to the trigger you saved — otherwise focus falls to <body> and the user is dumped at the top of the page.
Lock the scroll without the jump
While the drawer is open the page must not scroll. document.body.style.overflow = "hidden" handles that, but it introduces a jolt: on desktop, hiding the scrollbar makes the page ~15px wider and everything shifts. The fix is to measure the scrollbar and put that width back as padding:
function lockScroll(){
const sbw = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden";
if (sbw > 0) document.body.style.paddingRight = sbw + "px";
}
Swipe to close
On touch you expect to fling the drawer away. Pointer events give one code path for mouse, touch and pen. On pointerdown capture the pointer and turn the transition off so the panel tracks the finger exactly. On pointermove translate it by the drag distance, clamped to the closing direction only. On pointerup, if the drag passed a third of the panel's size, close; otherwise clear the inline transform and let the .open class snap it back.
The bits people forget
Mark the panel role="dialog" aria-modal="true" and label it with aria-labelledby. While closed, the off-screen panel is still in the DOM, so its controls are technically tabbable — set the inert attribute to switch the whole subtree off, and remove it on open. Finally, honour prefers-reduced-motion by dropping the transitions, so the panel appears and disappears with no travel for people who ask for less motion. Same logic, one CSS block.
That's the whole thing: a fixed panel, a transform, and a small amount of focus and scroll bookkeeping. No library — and once you've written it once, every mobile menu and cart drawer stops being magic.
Top comments (0)