Drawer
.drawer is a native <dialog> pinned to one inline edge
of the viewport, full height, sliding in from off-screen. Like
modal it borrows the focus trap, the escape key and the
backdrop from the element; what it adds is an edge, a size and a slide.
When to use it
Use a drawer for a panel of secondary controls that the reader opens, works in, and closes: filters, settings, a navigation menu on a phone, a details panel beside a list. It is taller than it is wide, so it suits a vertical list of things.
Open it with showModal(), for the same reasons as a modal — it is the
only method that gives the top layer, the backdrop, the focus trap and the inert
background.
<button class="btn" onclick="document.getElementById('dx-drawer').showModal()">Filters</button>
<dialog class="drawer" id="dx-drawer" aria-labelledby="dx-drawer-t">
<div class="drawer-header">
<h3 class="drawer-title" id="dx-drawer-t">Filters</h3>
<button class="btn btn-sm btn-ghost btn-icon push" aria-label="Close" onclick="this.closest('dialog').close()">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
<div class="drawer-body">
<label class="check"><input type="checkbox" checked><span>Overdue</span></label>
<label class="check"><input type="checkbox"><span>Unassigned</span></label>
<label class="check"><input type="checkbox"><span>Archived</span></label>
</div>
<div class="drawer-footer">
<button class="btn push" onclick="this.closest('dialog').close()">Clear</button>
<button class="btn btn-primary" onclick="this.closest('dialog').close()">Apply</button>
</div>
</dialog>
<button class="btn" onclick="document.getElementById('dx-drawer').showModal()">Filters</button>
<dialog class="drawer" id="dx-drawer" aria-labelledby="dx-drawer-t">
<div class="drawer-header">
<h3 class="drawer-title" id="dx-drawer-t">Filters</h3>
<button class="btn btn-sm btn-ghost btn-icon push" aria-label="Close" onclick="this.closest('dialog').close()">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
<div class="drawer-body">
<label class="check"><input type="checkbox" checked><span>Overdue</span></label>
<label class="check"><input type="checkbox"><span>Unassigned</span></label>
<label class="check"><input type="checkbox"><span>Archived</span></label>
</div>
<div class="drawer-footer">
<button class="btn push" onclick="this.closest('dialog').close()">Clear</button>
<button class="btn btn-primary" onclick="this.closest('dialog').close()">Apply</button>
</div>
</dialog>
Which edge
By default the drawer is pinned to the starting edge —
inset-inline-start: 0 — and slides in with
translate: -100% 0. .drawer-end swaps it to the ending edge
and reverses the translate.
Both are logical, so in an RTL document the starting edge is the right and the
animation reverses with it. There is no .drawer-left, and that is the
point.
<button class="btn" onclick="document.getElementById('dx-drawer-end').showModal()">Open from the end edge</button>
<dialog class="drawer drawer-end" id="dx-drawer-end" aria-labelledby="dx-drawer-end-t">
<div class="drawer-header">
<h3 class="drawer-title" id="dx-drawer-end-t">Details</h3>
<button class="btn btn-sm btn-ghost btn-icon push" aria-label="Close" onclick="this.closest('dialog').close()">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
<div class="drawer-body"><p class="text-muted">Slides in from the other side.</p></div>
</dialog>
<button class="btn" onclick="document.getElementById('dx-drawer-end').showModal()">Open from the end edge</button>
<dialog class="drawer drawer-end" id="dx-drawer-end" aria-labelledby="dx-drawer-end-t">
<div class="drawer-header">
<h3 class="drawer-title" id="dx-drawer-end-t">Details</h3>
<button class="btn btn-sm btn-ghost btn-icon push" aria-label="Close" onclick="this.closest('dialog').close()">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
<div class="drawer-body"><p class="text-muted">Slides in from the other side.</p></div>
</dialog>
Size
--drawer-size defaults to min(22rem, 88vw). The
88vw half is what makes it usable on a phone: a fixed
22rem drawer on a 360px screen would cover everything and leave no
visible backdrop to tap.
<button class="btn" onclick="document.getElementById('dx-drawer-wide').showModal()">A wider drawer</button>
<dialog class="drawer drawer-end" id="dx-drawer-wide" style="--drawer-size:min(32rem, 92vw)" aria-labelledby="dx-drawer-wide-t">
<div class="drawer-header"><h3 class="drawer-title" id="dx-drawer-wide-t">Wider</h3></div>
<div class="drawer-body">
<p class="text-muted">--drawer-size set inline. Keep a vw term in it.</p>
<button class="btn" onclick="this.closest('dialog').close()">Close</button>
</div>
</dialog>
<button class="btn" onclick="document.getElementById('dx-drawer-wide').showModal()">A wider drawer</button>
<dialog class="drawer drawer-end" id="dx-drawer-wide" style="--drawer-size:min(32rem, 92vw)" aria-labelledby="dx-drawer-wide-t">
<div class="drawer-header"><h3 class="drawer-title" id="dx-drawer-wide-t">Wider</h3></div>
<div class="drawer-body">
<p class="text-muted">--drawer-size set inline. Keep a vw term in it.</p>
<button class="btn" onclick="this.closest('dialog').close()">Close</button>
</div>
</dialog>
Header, body, footer
The drawer is a flex column, so the three regions divide the full height between
them: header and footer are flex: 0 0 auto, the body is
flex: 1 1 auto with overflow-y: auto. A long list of filters
scrolls while the Apply button stays where the reader last saw it.
overscroll-behavior: contain on the body stops a scroll that reaches the
end from chaining to the page behind — the effect where a drawer scrolls to its
bottom and then the whole page starts moving underneath it.
Both the header and the footer take safe-area insets:
padding-block-start: max(var(--space-4), env(safe-area-inset-top)) and
the matching bottom. On a phone with a notch and a home indicator, the title clears
the first and the buttons clear the second.
Classes
Generated from src/22-nav.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.drawer |
deck.components |
A side panel. The bottom sheet is in 08-mobile; this one comes in from an edge and is the right shape for filters, settings, and a mobile nav. | 22-nav.css:180 |
.drawer-body |
deck.components |
The scrolling region. overscroll-behavior: contain stops a scroll that reaches the end from chaining to the page behind. | 22-nav.css:243 |
.drawer-end |
deck.components |
Pins the drawer to the ending edge instead of the starting one. The inset is logical and mirrors for free; the translate is not, which is why this rule declares its own. | 22-nav.css:210 |
.drawer-footer |
deck.components |
The action row, pinned to the bottom. Pads for the home indicator with max(--space-4, safe-area-inset-bottom). | 22-nav.css:254 |
.drawer-header |
deck.components |
The title row. Pads for a notch with max(--space-4, safe-area-inset-top). | 22-nav.css:230 |
.drawer-title |
deck.components |
The drawer's heading. Point aria-labelledby at it. | 22-nav.css:240 |
Tokens
--drawer-size is local to the component rather than a global token,
because a drawer's width is a per-instance decision.
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-md |
1.0625rem |
|
--shadow-4 |
0 8px 16px oklch(20% .03 var(--shadow-color) / .08), 0 24px 56px oklch(20% .03 var(--shadow-color) / .14) |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
|
--space-5 |
1.25rem |
|
--dur-3 |
300ms |
|
--ease-emphasized |
cubic-bezier(.2, 0, 0, 1) |
|
--hue-neutral |
232 |
Accessibility
- Everything a modal gets, a drawer gets — focus trap, Escape, inert background, focus returned to the trigger — because it is the same element opened the same way.
-
Name it.
aria-labelledbypointing at.drawer-title, as every example on this page does. A drawer with no name is announced as "dialog". - Include a visible close button. Escape works, but a pointer user on a touch device has no Escape key, and the backdrop is not a dismiss target unless you make it one.
- Focus order is markup order. The drawer is at the end of the document but in the top layer, so tabbing stays inside it. Put the close button first or last deliberately — first is easier to reach, last keeps it out of the way of the content.
-
A navigation drawer is still navigation. Put a
<nav>with a label inside it; the dialog role does not substitute for a landmark. -
Full height and small screens.
block-size: 100dvhtracks a phone browser's chrome as it hides and shows, so the footer is not pushed under the address bar. This is the same reasoning as.app-shell. - The slide is long. 300ms with an emphasized easing — deliberate, because a panel travelling the width of itself needs time to read as travel rather than as a flash. It is collapsed under reduced motion.
Right to left
inset-inline-start, border-inline-end and
.drawer-end's inset-inline: auto 0 are all logical, so the
default drawer opens from the right in an Arabic document and
.drawer-end opens from the left.
The one thing that is not logical is the translate:
translate: -100% 0 is a physical X offset, and it does not flip. It
works because .drawer-end declares translate: 100% 0
separately — so the pairing is correct in both directions by construction rather than
by the property doing it. If you add a third edge variant, you have to remember this.
<button class="btn" onclick="document.getElementById('dx-drawer-rtl').showModal()">افتح اللوحة</button>
<dialog dir="rtl" class="drawer" id="dx-drawer-rtl" aria-labelledby="dx-drawer-rtl-t">
<div class="drawer-header"><h3 class="drawer-title" id="dx-drawer-rtl-t">عوامل التصفية</h3></div>
<div class="drawer-body">
<label class="check"><input type="checkbox" checked><span>متأخرة</span></label>
<button class="btn" onclick="this.closest('dialog').close()">إغلاق</button>
</div>
</dialog>
<button class="btn" onclick="document.getElementById('dx-drawer-rtl').showModal()">افتح اللوحة</button>
<dialog dir="rtl" class="drawer" id="dx-drawer-rtl" aria-labelledby="dx-drawer-rtl-t">
<div class="drawer-header"><h3 class="drawer-title" id="dx-drawer-rtl-t">عوامل التصفية</h3></div>
<div class="drawer-body">
<label class="check"><input type="checkbox" checked><span>متأخرة</span></label>
<button class="btn" onclick="this.closest('dialog').close()">إغلاق</button>
</div>
</dialog>
Reduced motion
The panel translates over 300ms and the
backdrop fades with it. Under prefers-reduced-motion: reduce the global
reset collapses both to .01ms, so the drawer is simply there and then
simply gone.
display and overlay stay in the transition list with
allow-discrete, so the close still completes correctly at the shortened
duration — the same mechanism explained on the
modal page.
Printing
An open drawer does not print at all. There is no .drawer rule in
src/99-print.css, but there is a second @layer deck.print block at the end of src/24-media.css, and
.drawer is in its display: none !important list alongside
.mega, .speed-dial and .banner.
dialog::backdrop { display: none } from the main print sheet removes the
grey wash as well, so neither the panel nor its scrim reaches the page.
That is the right default: a drawer holds controls rather than content, and a fixed-position panel printed over the top of the document would obscure the thing the reader actually wanted. If its contents need to be on paper, they belong in the page.
Overriding it
/* One drawer, no layer needed */
<dialog class="drawer" style="--drawer-size:min(28rem, 90vw)">
@layer app.components {
/* A drawer that does not reach the full height */
.drawer-inset {
inset-block: var(--space-4);
block-size: auto;
border-radius: var(--r-lg);
border: 1px solid var(--line);
}
/* Light dismiss, which Deck does not add */
/* (needs one line of JS: close when the click target is the dialog itself) */
}
Keep a viewport term in --drawer-size. A fixed width with no
vw component covers the whole screen on a phone and leaves no backdrop
to indicate there is anything behind it.
When not to use it
-
Not on a phone, for most things. A side panel is a desktop shape.
On a phone
.sheetcomes from the bottom, where the thumb is, and does not cover the whole screen. -
Not for a confirmation. Use
.modal. A drawer is a workspace; a confirmation is a question, and it should be centred and small. -
Not for a menu. Use
.menu— a popover with light dismiss and no focus trap. A drawer for five actions blocks the page for something that should not. -
Not as permanent navigation. A sidebar that is always visible is
.sidebarinside an.app-shell. A drawer is for something that opens and closes. -
Not for content the reader needs to compare with the page. The
background is inert, so they cannot interact with it while the drawer is open. If
they need both, use a
.splitrail.