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.

Opens from the starting edge; Escape closes it

Filters

<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.

Details

Slides in from the other side.

<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.

Wider

--drawer-size set inline. Keep a vw term in it.

<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.

Classes, generated from the stylesheet source
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.

Design tokens this component reads
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-labelledby pointing 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: 100dvh tracks 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.

Opens from the right, because that is the starting edge

عوامل التصفية

<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 .sheet comes 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 .sidebar inside 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 .split rail.