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 and .sheet from the bottom, with .sheet-handle inside
the header's own top padding; .drawer-body is the only scrolling part, and
data-sheet on a <dialog> opts the handle into the drag
(the script carries .sheet--dragging for its duration) — a close button is
still what makes a sheet dismissible.
<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" data-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, and the header carries the
.list-row lockup with --flush because the
header already supplies the padding. Everything that scrolls goes in
.drawer-body, and aria-labelledby points at the title rather than
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-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 wider drawer
Live — open it. A drawer is 420px, which is enough for a form and
too little for a picture or a wide table. Set --drawer-width on the drawer to
any length: 560px here, or max(420px, 33vw) for one that grows
with the window. The viewport still caps it, so it is edge to edge on a phone. A .sheet takes the same knob, and its default is 480px.
<button class="btn" type="button" onclick="document.getElementById('photo-detail').showModal()">
<i class="ri-image-line"></i> Open the photo
</button>
<dialog class="drawer" id="photo-detail" aria-labelledby="photo-detail-title"
style="--drawer-width: 560px">
<div class="drawer-header">
<h3 id="photo-detail-title">racking-bay-4-after-the-move.jpg</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="media-frame media-frame--cover">
<img src="https://images.unsplash.com/photo-1587293852726-70cdb56c2866?w=960&h=600&fit=crop&q=70&auto=format"
width="960" height="600" loading="lazy" alt="Racking in bay 4, restocked after the move" />
</div>
<div class="kv"><span class="k">Size</span><span class="v">3.4 MB</span></div>
<div class="kv"><span class="k">Taken</span><span class="v">12 Sep 2026</span></div>
<p class="form-hint sedna-mb-0">Photograph by CHUTTERSNAP on Unsplash.</p>
</div>
<div class="drawer-footer">
<button class="btn" type="button" onclick="this.closest('dialog').close()">Close</button>
</div>
</dialog>
A sheet of actions
Live — open it. A sheet whose body is a list of
actions, the phone form of a menu: each row is a
<button class="list-row"> filling the <li>, so the
whole row is the target and the count is announced. The body is
.drawer-body--flush so the rows meet the sheet's edges; reach for a sheet
rather than a menu when the actions need a second line each, or when a 36px menu row is a
small target on a touch screen.
<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" data-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 drawer-body--flush">
<ul class="list">
<li>
<button class="list-row" 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" 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" 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 as its own component
Live — open it. ISednaOverlays.ShowAsync<TComponent, TResult>
renders your component, opens the <dialog> it wrote and completes with
what it closed with — Overlay.CloseAsync(result), or the default for
Escape and CancelAsync(). The component takes a cascading
SednaOverlay and puts its Id on the dialog; every element and word
on screen is still in your file, and the drawer is removed only once it has slid out. Place
<SednaOverlayHost /> once in the layout. The same works for a
.modal or a .sheet, and ISednaUi.ShowModalAsync(id)
opens any of the three when the dialog is already in the page.
The <dialog> has to be in the component's first render,
because it is opened as soon as the component has rendered once. Load data inside it,
behind a .skeleton. Use a nullable result, such as
bool?, where cancelled has to differ from a real false.
@inject ISednaOverlays Overlays
<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
<button class="btn" type="button" @onclick="Reassign">
<i class="ri-user-shared-line"></i> Reassign ORD-4209
</button>
<span class="form-hint">Owner</span>
<span class="badge">@_owner</span>
</div>
@code {
private string _owner = "Alex Fischer";
// ReassignOrder writes its own <dialog class="drawer">. This renders it, opens it,
// and waits for the owner it closes with — null for Escape, Cancel or the close button.
private async Task Reassign()
{
var owner = await Overlays.ShowAsync<ReassignOrder, string>(
new() { [nameof(ReassignOrder.Current)] = _owner });
if (owner is not null) _owner = owner;
}
}
@* The app's own overlay component. Everything on screen is written here — the drawer,
its words and its buttons — and ISednaOverlays only presents it. *@
<dialog id="@Overlay.Id" class="drawer" aria-labelledby="@($"{Overlay.Id}-title")">
<div class="drawer-header">
<h3 id="@($"{Overlay.Id}-title")">Reassign ORD-4209</h3>
<button class="modal-close" type="button" aria-label="Close" @onclick="Overlay.CancelAsync">
<i class="ri-close-line"></i>
</button>
</div>
<div class="drawer-body">
<div class="form-field">
<label class="form-label" for="@($"{Overlay.Id}-owner")">New owner</label>
<select class="form-input form-select" id="@($"{Overlay.Id}-owner")" @bind="_owner">
@foreach (var person in People)
{
<option value="@person">@person</option>
}
</select>
</div>
<p class="form-hint">Currently @Current. The new owner is notified.</p>
</div>
<div class="drawer-footer">
<button class="btn" type="button" @onclick="Overlay.CancelAsync">Cancel</button>
<button class="btn btn-primary" type="button" disabled="@(_owner == Current)"
@onclick="() => Overlay.CloseAsync(_owner)">Reassign</button>
</div>
</dialog>
@code {
private static readonly string[] People = ["Alex Fischer", "Priya Nair", "Jordan Weiss"];
private string _owner = "";
[CascadingParameter] public SednaOverlay Overlay { get; set; } = default!;
[Parameter] public string Current { get; set; } = "";
protected override void OnInitialized() => _owner = Current;
}
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; its closed state is
visibility: hidden rather than a translate off-screen, or 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 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>