Drawers and sheets
tier 2 — classesA panel that comes in from an edge, for a secondary flow that should not lose the page behind it — a filter panel, a record's detail, a form on a phone.
From each edge
Live — open one. Each is a <dialog class="drawer"> opened
with showModal(), so it slides in from its edge, Escape closes it,
focus stays inside it, and ::backdrop dims the page with no
.drawer-scrim in the markup at all.
.drawer--start comes from the other edge. .sheet comes from the
bottom — the right shape on a phone, where a side panel has nowhere to come from.
.sheet-handle is decoration, not a control: dragging is a gesture an app
implements, and the close button is what makes the sheet dismissible for everybody else.
.drawer-body is the only scrolling part, so the header and footer stay put. The
slide survives display: none through
transition-behavior: allow-discrete and @starting-style; without
them the panel appears rather than slides, which is abrupt rather than broken.
<button class="btn" type="button" onclick="document.getElementById('filters').showModal()">
<i class="ri-filter-3-line"></i> Filters
</button>
<button class="btn" type="button" onclick="document.getElementById('nav-drawer').showModal()">
<i class="ri-side-bar-line"></i> From the start edge
</button>
<button class="btn" type="button" onclick="document.getElementById('assign-sheet').showModal()">
<i class="ri-layout-bottom-line"></i> As a sheet
</button>
<dialog class="drawer" id="filters" aria-labelledby="filters-title">
<div class="drawer-header">
<h3 id="filters-title">Filter the queue</h3>
<button class="modal-close" type="button" aria-label="Close"
onclick="this.closest('dialog').close()"><i class="ri-close-line"></i></button>
</div>
<div class="drawer-body">
<div class="form-field">
<label class="form-label" for="d-group">Assignment group</label>
<select class="form-input form-select" id="d-group">
<option>Any</option><option selected>EU-West</option>
</select>
</div>
<div class="form-field">
<label class="form-label" for="d-priority">Priority</label>
<select class="form-input form-select" id="d-priority">
<option>Any</option><option selected>P2 and above</option>
</select>
</div>
<p class="form-hint">Filters apply as you change them.</p>
</div>
<div class="drawer-footer">
<button class="btn" type="button" onclick="this.closest('dialog').close()">Reset</button>
<button class="btn btn-primary" type="button" onclick="this.closest('dialog').close()">Done</button>
</div>
</dialog>
<dialog class="drawer drawer--start" id="nav-drawer" aria-labelledby="nav-drawer-title">
<div class="drawer-header">
<h3 id="nav-drawer-title">Queues</h3>
<button class="modal-close" type="button" aria-label="Close"
onclick="this.closest('dialog').close()"><i class="ri-close-line"></i></button>
</div>
<div class="drawer-body">
<p>The same panel from the inline start edge, for navigation on a narrow screen.</p>
</div>
</dialog>
<dialog class="drawer sheet" id="assign-sheet" aria-labelledby="assign-sheet-title">
<div class="sheet-handle"></div>
<div class="drawer-header">
<h3 id="assign-sheet-title">Reassign ORD-4209</h3>
<button class="modal-close" type="button" aria-label="Close"
onclick="this.closest('dialog').close()"><i class="ri-close-line"></i></button>
</div>
<div class="drawer-body">
<p>From the bottom edge — the right shape on a phone, where a side panel has nowhere to come from.</p>
</div>
<div class="drawer-footer">
<button class="btn btn-primary" type="button" onclick="this.closest('dialog').close()">Reassign</button>
</div>
</dialog>
A record's detail
Live — open it. The commonest use: the row stays on screen behind the panel,
so the reader keeps their place in the list. The header carries the
.list-row lockup with --flush, because the
header already supplies the padding.
Everything that scrolls goes in .drawer-body. Point
aria-labelledby at the title, not at the panel: the title is what a screen reader
announces when focus enters.
<button class="btn" type="button" onclick="document.getElementById('order-detail').showModal()">
<i class="ri-side-bar-right-line"></i> Open ORD-4209
</button>
<dialog class="drawer" id="order-detail" aria-labelledby="order-detail-title">
<div class="drawer-header">
<div class="list-row list-row--flush">
<span class="avatar"><i class="ri-shopping-bag-3-line"></i></span>
<span class="list-main">
<span class="list-title" id="order-detail-title">ORD-4209</span>
<span class="list-sub">Northwind Retail · EU-West</span>
</span>
</div>
<button class="modal-close" type="button" aria-label="Close"
onclick="this.closest('dialog').close()"><i class="ri-close-line"></i></button>
</div>
<div class="drawer-body">
<div class="kv"><span class="k">State</span><span class="v"><span class="badge badge-warn">Pending</span></span></div>
<div class="kv"><span class="k">Lines</span><span class="v">5</span></div>
<div class="kv"><span class="k">Reserved</span><span class="v">3 of 5</span></div>
<div class="kv"><span class="k">Raised</span><span class="v">31 Jul 2026, 09:14</span></div>
<hr class="divider" />
<ol class="timeline">
<li>
<span class="timeline-when">09:14</span>
<span class="timeline-what">Order received from the storefront.</span>
</li>
<li>
<span class="timeline-when">09:15</span>
<span class="timeline-what">Three of five lines reserved automatically.</span>
</li>
<li>
<span class="timeline-when">09:18</span>
<span class="timeline-what">Held for a decision — two lines are out of stock.</span>
</li>
</ol>
</div>
<div class="drawer-footer">
<button class="btn" type="button" onclick="this.closest('dialog').close()">Close</button>
<button class="btn btn-go" type="button" onclick="this.closest('dialog').close()">
<i class="ri-check-line"></i> Dispatch what is reserved
</button>
</div>
</dialog>
A sheet of actions
Live — open it. A sheet whose body is a list of
actions, which is the phone form of a menu. Each row is a real
<button> filling the <li>, so the whole row is the
target and the count is announced.
Use a sheet rather than a menu when the actions need a second line each, or when the reader is on a touch screen where a 36px menu row is a small target.
<button class="btn" type="button" onclick="document.getElementById('order-actions').showModal()">
<i class="ri-more-2-line"></i> Order actions
</button>
<dialog class="drawer sheet" id="order-actions" aria-labelledby="order-actions-title">
<div class="sheet-handle"></div>
<div class="drawer-header">
<h3 id="order-actions-title">ORD-4209</h3>
<button class="modal-close" type="button" aria-label="Close"
onclick="this.closest('dialog').close()"><i class="ri-close-line"></i></button>
</div>
<div class="drawer-body">
<ul class="list">
<li>
<button class="list-row dr-w-full text-start" type="button"
onclick="this.closest('dialog').close()">
<i class="ri-send-plane-line"></i>
<span class="list-main">
<span class="list-title">Dispatch what is reserved</span>
<span class="list-sub">Three of five lines</span>
</span>
</button>
</li>
<li>
<button class="list-row dr-w-full text-start" type="button"
onclick="this.closest('dialog').close()">
<i class="ri-user-shared-line"></i>
<span class="list-main">
<span class="list-title">Reassign</span>
<span class="list-sub">Currently Alex Fischer</span>
</span>
</button>
</li>
<li>
<button class="list-row dr-w-full text-start" type="button"
onclick="this.closest('dialog').close()">
<i class="ri-timer-line"></i>
<span class="list-main">
<span class="list-title">Hold for stock</span>
<span class="list-sub">Re-check every hour</span>
</span>
</button>
</li>
</ul>
</div>
</dialog>
A drawer that is not modal
.drawer-scrim and .drawer-scrim--open are for a drawer the reader
can deliberately leave open while working on the page behind it. Closed state is
visibility: hidden, not just a translate off-screen: a panel that is merely moved
away is still tabbable, and Tab walks the reader into a panel they cannot see.
A drawer with a scrim is modal in behaviour, so make it a
<dialog> instead — the scrim becomes ::backdrop, and the
dismissal, the focus trap and Escape stop being yours to write.
<!-- Only for a drawer that is deliberately NOT modal — one the reader can leave
open while working on the page behind it. `.drawer-scrim` immediately after the
panel is what dims the page; the two open classes are yours to toggle, and so
are Escape and the focus handling. A drawer WITH a scrim is modal in behaviour,
so it should be a <dialog> instead. -->
<div class="drawer drawer--open" role="dialog" aria-labelledby="filters-title">
<div class="drawer-header">
<h3 id="filters-title">Filter the queue</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="drawer-body">…</div>
</div>
<div class="drawer-scrim drawer-scrim--open"></div>