Drawers are modal panels docked to an edge of the viewport, built on the native <dialog> element. They share their machinery with Dialog — the same focus trapping, the same Escape handling, the same script. A drawer is the right choice when the content sits beside the page rather than interrupting it: navigation, filters, a detail view, a settings panel. The class is required, and data-placement chooses the edge.
Anatomy
| Axis | Mechanism | Example |
|---|---|---|
| Edge | data-placement |
data-placement="start" |
| Backdrop press | data-static |
data-static |
| Drag to dismiss | markup | <div class="drawer-handle"> |
| Width or height | --drawer-size: 32rem |
The drawer defines its own component tokens, all prefixed --drawer-*. Re-point those to re-skin it; never override the rules themselves.
Basic usage
A drawer is a <dialog class="drawer">. A trigger carrying data-drawer-open opens it by id; any control inside carrying data-drawer-close closes it.
<button class="button" type="button" data-drawer-open="my-drawer">Open drawer</button>
<dialog id="my-drawer" class="drawer" aria-labelledby="my-drawer-title">
<div class="drawer-header">
<h2 class="drawer-title" id="my-drawer-title">Settings</h2>
<button class="button close-btn" type="button" data-icon-only data-size="small"
data-drawer-close aria-label="Close"><!-- close icon --></button>
</div>
<div class="drawer-body">…</div>
</dialog>
Placement
Four edges. start and end are inline edges and take a width; top and bottom are block edges and take a height. Omitting the attribute gives you end.
<dialog class="drawer" data-placement="start">…</dialog>
<dialog class="drawer" data-placement="end">…</dialog> <!-- default -->
<dialog class="drawer" data-placement="top">…</dialog>
<dialog class="drawer" data-placement="bottom">…</dialog>
start and end follow the writing direction, so a right-to-left page docks them to the opposite side and the slide direction flips with them.
Size
One token covers both axes, because only one is ever free: --drawer-size is a width on start and end, a height on top and bottom. It defaults to 25rem and is capped at the viewport, so a wide drawer degrades to full-bleed on a narrow screen rather than overflowing.
.filters-drawer {
--drawer-size: 32rem;
}
A drawer that is only a drawer sometimes
This site's own navigation is one element in two modes: <dialog class="site-sidebar drawer">. Below 768px it is a real modal drawer; above, CSS overrides the UA's display: none and it behaves as an ordinary in-flow sidebar, and because the button that opens it is hidden at that width, showModal() is never called and it never enters the top layer.
It is worth knowing this is possible, and worth knowing what it costs. The desktop rules have to undo the drawer's closed state — opacity, translate, the placement insets, size, padding, gap and shadow — and they need a selector specific enough to win, because the placement rules here are (0,3,0). Add a property to the closed state and every dual-mode consumer must learn about it.
Take this route when the alternative is rendering the same navigation twice; take the two-element route when it is not.
Two things make it work rather than merely render:
- A modal is what removes the drawer's contents from the accessibility tree. Moving a panel off-screen with
transformleaves every link inside it reachable by Tab and by screen-reader swipe, invisibly. - A viewport can grow past the breakpoint while the drawer is open, which would strand an open modal styled as a sidebar. Watch the media query and call
window.bdRequestClose()when it stops matching.
Header actions
holds supplementary controls beside the close button — a settings shortcut, a link out, an overflow menu.
<div class="drawer-header">
<h2 class="drawer-title" id="report-title">Report</h2>
<div class="drawer-header-actions">
<button class="button" type="button" data-variant="transparent" data-icon-only
data-size="small" aria-label="Download"><!-- icon --></button>
<button class="button close-btn" type="button" data-icon-only data-size="small"
data-drawer-close aria-label="Close"><!-- close icon --></button>
</div>
</div>
Without a header
Omit entirely. There is no attribute for this — the header is markup you write, so leaving it out is all it takes. A drawer with no header has no visible title, so it must carry aria-label, and it must still offer a way out: a close control somewhere in the body, or the backdrop left dismissible (no data-static).
<dialog class="drawer" data-placement="start" aria-label="Navigation">
<div class="drawer-body">
<nav>…</nav>
</div>
</dialog>
Footer
pins actions to the bottom while the body scrolls between it and the header.
<div class="drawer-footer">
<button class="button" data-variant="faded" type="button" data-drawer-close>Reset</button>
<button class="button" type="button" data-drawer-close autofocus>Apply</button>
</div>
Drag to dismiss
Add a and the drawer can be pulled off its own edge. There is no attribute for this, for the same reason the header has none: the handle is markup you write, so a drawer can never show a grip it does not respond to, or respond to a gesture it never advertised.
<dialog class="drawer" data-placement="bottom" aria-labelledby="share-title">
<div class="drawer-handle" aria-hidden="true"></div>
<div class="drawer-header">…</div>
<div class="drawer-body">…</div>
</dialog>
The handle docks to the drawer's free edge — the one facing the page, opposite the edge it is docked to — because that is the edge the gesture pulls away from. A bottom sheet grips at the top, a start drawer at its inline end. The drawer reserves that strip as padding rather than letting it overlay content, so the grab area never swallows a click meant for the panel.
Releasing dismisses the drawer on either of two independent tests: dragged to at least half its own extent, or flicked fast enough having first travelled a minimum distance, so a jittery tap cannot read as a flick. Anything short of both returns it to its edge. Pulling against the closing direction clamps at zero rather than stretching — a drawer pulled past its own edge has nothing to reveal.
One behaviour to know rather than fix: the grab strip claims the drag axis from the browser, so a touch scroll started inside the strip along that axis drags the drawer instead of scrolling the body. The reserved padding keeps content out of the strip, and the cross axis still scrolls — but a consumer will report this as a scrolling bug if nobody tells them it is the gesture.
A dismissal is a close like any other, so it fires drawer-hide with event.detail.source of "drag" and a guard can refuse it. A refused drag slides back and pulses.
The gesture is a redundant pointer shortcut, never the only way out. Mark the handle aria-hidden="true", and keep a close button or a live backdrop — that is the single-pointer, non-dragging alternative WCAG 2.5.7 requires, and Escape covers the keyboard separately. A handle on a data-static drawer with no close button leaves pointer users with drag as their only exit; the module warns in the console when it sees that combination.
Static drawers
Pressing the backdrop closes the drawer by default. Add data-static when the drawer holds unsaved work and closing it by accident would lose something.
<dialog class="drawer" data-static aria-labelledby="edit-title">…</dialog>
The attribute is presence-only — there is no data-static="false". A valued opt-out would fail unsafe: any typo in the value reads as "not false" and leaves the backdrop live on the one drawer that was being protected.
With data-static the backdrop stops responding entirely, so the close button and Escape become the only ways out. One of them must always be present.
data-static and the drag handle are orthogonal — it does not suppress the gesture. The attribute guards against an accidental backdrop press, and a deliberate drag past half the panel is not accidental. A static drawer that must also refuse a drag guards drawer-hide on source === 'drag'.
Guarding the close
Every user-driven close fires a cancellable drawer-hide event before anything happens. Calling preventDefault() keeps the drawer open and pulses it, so a blocked close reads as deliberate rather than broken.
event.detail.source says what asked to close: "close-button", "backdrop", "escape" or "drag".
const drawer = document.getElementById('edit-drawer');
drawer.addEventListener('drawer-hide', (event) => {
// Let the explicit close button through; guard the accidental paths.
if (event.detail.source === 'close-button') return;
if (hasUnsavedChanges()) event.preventDefault();
});
A direct drawer.close() bypasses the guard — the native method cannot be intercepted. Route programmatic closes through window.bdRequestClose(drawer, 'programmatic') when the guard should apply.
The guard is a safety net, not a lock. Two Escape presses in a row with no interaction between them always close the drawer: the browser only makes the first close request cancellable, and cancelling it consumes the user activation that would make the next one cancellable too. Treat drawer-hide as protection against an accidental close, and never as the only thing standing between a user and lost work — persist a draft as well.
JavaScript
Include assets/js/dialog.js on any page with a drawer. One script serves both Dialog and Drawer; they are the same machinery, so there is no separate drawer.js to add.
React products can render this contract through the packaged adapter, exported as both <Drawer> and <Sheet> — the second name for the bottom-docked sheet shape, which is what its defaults assume. See React.
<script src="/assets/js/dialog.js" defer></script>
Every listener is delegated from document, so drawers rendered after load work without re-initialising.
| API | Purpose |
|---|---|
data-drawer-open="id" |
On a trigger, opens that drawer |
data-drawer-close |
Inside a drawer, closes it |
drawer-hide |
Cancellable event fired before closing, with detail.source |
| Markup that enables drag to dismiss — no attribute, no init | |
window.bdRequestClose(el, source) |
Closes through the guard |
window.bdCloseOpenDialogs() |
Force-closes every open dialog and drawer, skipping guards |
A client-side router must call bdCloseOpenDialogs() before swapping page content. A modal that survives the swap keeps the new page inert; one destroyed while open strands focus with no announcement.
Accessibility
- Every drawer must be named. Use
aria-labelledbypointing at , oraria-labelwhen there is no header - Focus trapping, Escape and backdrop inertness are native to
<dialog>opened withshowModal()— do not reimplement them - Put
autofocuson the control the user most likely wants. Without it the browser focuses the first focusable element, which is usually the close button - Icon-only controls in the header must carry
aria-label, including the close button - The page behind a drawer is locked from scrolling while it is open, so a keyboard user cannot lose their place
- Escape is intercepted so it runs through the same guard as the other close paths. A drawer that blocks Escape must offer a visible close control, or the keyboard user has no way out
- Reduced motion replaces the slide with a short fade, and replaces the blocked-close pulse with a backdrop flash rather than dropping the feedback entirely. Drag to dismiss keeps working: a drawer following a finger is direct manipulation, not decorative motion, and removing it would take away a way out rather than calm one down
- A drag handle is decorative — mark it
aria-hidden="true". The gesture is a redundant pointer shortcut: the close button or a live backdrop is its single-pointer non-dragging alternative (WCAG 2.5.7), Escape its keyboard one. It must never be the only way out - The handle's own grab area is
--drawer-handle-target(44px) thick, so the strip clears the hit-area floor even though the visible pill is slim. That claim is scoped to the handle — the header close button is a separate control with its own open target-size entry on the roadmap
Usage rules
Do:
- Use a drawer when content sits beside the page — navigation, filters, a detail view
- Put it as a direct child of
<body>, or at least outside[data-barba="container"]. A drawer destroyed mid-transition while open leaves the top layer and focus inconsistent - Use
startfor navigation andendfor detail and filter panels, consistently across a product - Guard the close when the drawer holds unsaved work
- Add a handle when the drawer is a mobile sheet — the gesture is what people reach for first there
Don't:
- Don't use a drawer for a confirmation or a short message — that is a Dialog
- Don't open a drawer from inside another drawer; two stacked modals leave no clear way back
- Don't remove the close button when light dismiss is off
- Don't set both and on one element — they are alternative treatments of the same base, not composable
- Don't add a handle to a drawer holding unsaved work without guarding
drawer-hide— the grip invites exactly the dismissal you are trying to prevent
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | What it controls |
|---|---|---|
| Panel background | ||
0 8px 32px var(--black-alpha-20) |
Panel shadow | |
0 |
Corner radius | |
| Inner padding | ||
| Backdrop fill | ||
25rem |
Width on inline edges, height on block edges | |
100% |
How far the drawer travels when closed | |
| Set per placement | Closed-state translate — direction only, magnitude comes from | |
| Entrance duration | ||
| Entrance easing | ||
| Exit duration | ||
| Exit easing | ||
| Grip fill. A control, so it answers to non-text contrast (WCAG 1.4.11) — it clears 3:1 in both themes (5.7:1 light, 5.0:1 dark against the dark panel's ), where both steps fail | ||
2.25rem |
Pill length along the free edge | |
0.25rem |
Pill thickness | |
| Grab-strip thickness and the padding reserved for it — one token so the two can never disagree. is the floor |
Selectors
| Selector | Purpose |
|---|---|
| Base component, closed/exit state | |
.drawer::backdrop |
Backdrop, closed/exit state |
.drawer:not([data-placement="top"]):not([data-placement="bottom"]) |
Inline-edge sizing — also catches an unrecognised value |
.drawer[data-placement="start"] |
Docked to the inline start edge |
.drawer:not([data-placement="start"]):not(…top):not(…bottom) |
Docked to the inline end edge — the default, and the fallback |
.drawer[data-placement="top"] |
Docked to the block start edge |
.drawer[data-placement="bottom"] |
Docked to the block end edge |
.drawer[data-placement="start"]:dir(rtl) |
Flips the slide direction to match the flipped inset |
.drawer[open] |
Open state and entrance transition |
.drawer[open]::backdrop |
Backdrop entrance transition |
@starting-style .drawer[open] |
Pre-open state, required to transition in from the top layer |
html:has(.drawer[open]), body:has(.drawer[open]) |
Locks page scroll while a drawer is open |
.dialog.is-pulsing, .drawer.is-pulsing |
Blocked-close feedback, shared with Dialog |
| Drag grip — absolute on the free edge, thick | |
.drawer[data-placement="top"] .drawer-handle, …="bottom" |
Block-edge grip: spans the inline axis, pill lies along it |
.drawer:not(…top):not(…bottom) .drawer-handle |
Inline-edge grip: spans the block axis, pill stands upright — also catches an unrecognised value |
.drawer:has(.drawer-handle) (per placement) |
Reserves the grip strip as padding so it never overlays content |
.drawer.is-dragging |
Suspends the transition for the length of a gesture |
@media (forced-colors: active) .drawer-handle::before |
Redraws the pill, whose fill the mode would otherwise erase |
| Title row | |
| Heading | |
| Supplementary header controls | |
| Scrolling content region | |
| Pinned action row | |
[data-theme="dark"] .drawer |
Dark mode token re-point |
Key rules
Placement pins one axis and stretches the other: inline placements set inset-block: 0 and take --drawer-size as inline-size; block placements set inset-inline: 0 and take it as block-size. The opposite inset is reset to auto so the UA's inset: 0 cannot stretch the drawer across the viewport — without that reset, an over-constrained end drawer would dock to the start edge instead.
The inline-edge rules are written as negations of the block-edge values rather than as a list of known values, so an unrecognised data-placement degrades to a working end drawer instead of a full-screen panel with no animation.
Travel is split from direction. --drawer-travel carries the magnitude and is declared once on ; the placement rules set direction only. That is what lets the reduced-motion block zero the travel — declaring the full offset on the placement rules would put it out of reach of a -level override, and reduced-motion users would get the whole slide compressed into a shorter duration.
Page scroll is locked on html and body while a drawer is open. That only reaches documents whose scroller is the root: a product that scrolls a container element, or drives scrolling through a library, has to lock its own scroller.
Entrance and exit use the same top-layer technique as Dialog — @starting-style to transition in, allow-discrete plus overlay to transition out. Engines missing either feature show and hide the drawer with no animation, which degrades cleanly rather than breaking.
Reduced motion re-points the motion tokens rather than disabling the transition, so the discrete display and overlay steps still run and the drawer cannot get stuck mid-exit. It leaves drag to dismiss alone — following a finger is direct manipulation, not decorative motion — but takes translate out of the exit: with travel zeroed, a dragged dismissal would otherwise tween all the way back toward its edge while fading, a large slide opposite the gesture delivered to exactly the users who asked for less motion. Instead the position cuts home in a single frame — a cut, not motion — and the drawer fades out from there. A cancelled drag still visibly returns home — that is feedback, not decoration.
The drag handle is positioned absolutely on the free edge and the drawer reserves that strip as extra padding on the same side. Overlaying it instead would put an invisible --target-min band over the panel's inner edge, swallowing clicks that nobody would attribute to the handle.
Pointer tracking lives in dialog.js, not in CSS. On release the module clears the inline translate it has been writing and hands the drawer back to the stylesheet — still open, it transitions home on the entrance tokens; closing, it continues off its edge on the exit tokens. Those two steps have to stay adjacent in the source: anything that reads layout between them flushes the cleared value and the drawer snaps home before it leaves.
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. Its behaviour ships as dist/js/dialog.js — copy it into the product's served assets and include it once per page:
<script src="assets/js/dialog.js" defer></script>
In React, render this contract through the packaged adapter instead of writing the markup by hand:
import { Drawer } from '@bydefaultstudio/design-system/react';
The adapter renders the contract above and bridges this component's events to props — see React.