Modal
tier 2 — classes
A question that has to be answered before anything else happens. .modal is the
panel; header, body and footer are three optional rows.
<dialog> and open it with showModal()
The top layer, the focus trap, Escape, inert content behind and returning focus on
close all come from the platform. Write no .modal-backdrop either:
::backdrop is the platform's own.
A real modal
Live — open it. The whole panel is wrapped in
<form method="dialog">, which is what makes every button in it close the
dialog with no JavaScript at all — value becomes
dialog.returnValue. Point aria-labelledby at the heading, or a
screen reader announces “dialog” and stops.
<button class="btn btn-warn-solid" type="button"
onclick="document.getElementById('takeover').showModal()">
<i class="ri-user-shared-line"></i> Take over this order
</button>
<dialog class="modal" id="takeover" aria-labelledby="takeover-title">
<form method="dialog">
<div class="modal-header">
<h3 id="takeover-title">Take over this order?</h3>
<button class="modal-close" value="cancel" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body">
<p>Automation stops handling <strong>ORD-4209</strong> and you become the owner. Any pending reservation on it expires.</p>
<p class="form-hint">A note is written to the order's work log in your name.</p>
</div>
<div class="modal-footer">
<button class="btn" value="cancel">Cancel</button>
<button class="btn btn-warn-solid" value="accept"><i class="ri-user-shared-line"></i> Accept & take over</button>
</div>
</form>
</dialog>
Opening one from C#
Live — open it. ISednaUi.ShowModalAsync(id) is the one line of
script the example above needs, without an inline onclick and without injecting
IJSRuntime. It completes when the dialog closes, with its
returnValue — what CloseModalAsync(id, returnValue) passed, or
null for Escape — so the result reads as ordinary C# control flow.
Both routes out call CloseModalAsync, and the form is a plain
<form> rather than <form method="dialog">: closing the
dialog is that attribute's default action, and Blazor's event delegator
preventDefaults a submit with an @onsubmit handler so the form
does not navigate. Submit and it is cancelled with it, so the dialog stays open and nothing is
logged. Handling it in C# is the honest shape here anyway — the work can fail, and a dialog
that has already closed cannot show why.
@inject ISednaUi Ui
<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
<button class="btn btn-primary" type="button" @onclick="Open">New API key</button>
@if (_result is null)
{
<span class="form-hint">No key created yet.</span>
}
else
{
<span class="badge @(_result.Saved ? "badge-go" : "")">
<i class="@(_result.Saved ? "ri-key-2-line" : "ri-close-circle-line")"></i>
@_result.Label
</span>
<span class="form-hint">
<code>returnValue</code> was <code>@_result.ReturnValue</code>
</span>
}
</div>
<dialog id="new-api-key" class="modal">
<form @onsubmit="Save">
<div class="modal-header">
<h3>New API key</h3>
<button class="modal-close" type="button" aria-label="Close" @onclick="Cancel">
<i class="ri-close-line"></i>
</button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label" for="key-name">Name</label>
<input class="form-input" id="key-name" @bind="_name" />
</div>
<div class="form-field">
<label class="form-label" for="key-expiry">Expires</label>
<input class="form-input" id="key-expiry" type="date" @bind="_expires" />
</div>
</div>
<div class="modal-footer">
<button class="btn" type="button" @onclick="Cancel">Cancel</button>
<button class="btn btn-primary" type="submit">Create</button>
</div>
</form>
</dialog>
@code {
private const string Id = "new-api-key";
private string _name = "ci-publisher";
private DateOnly _expires = new(2027, 3, 31);
private Outcome? _result;
private sealed record Outcome(bool Saved, string Label, string ReturnValue);
// Completes when the dialog closes, with its returnValue: "save" or "cancel" from the
// two buttons below, and null for Escape.
private async Task Open()
{
_result = null;
var returnValue = await Ui.ShowModalAsync(Id);
_result = returnValue == "save"
? new Outcome(true, $"{_name} · expires {_expires:yyyy-MM-dd}", "save")
: new Outcome(false, "Cancelled", returnValue ?? "null");
}
// Both routes out close the dialog through CloseModalAsync, and the form is a
// plain <form> rather than <form method="dialog">.
//
// The platform's own dialog-method submit does not survive a Blazor handler:
// closing the dialog and setting returnValue is the DEFAULT ACTION of the submit
// event, and Blazor's event delegator preventDefaults every submit that has an
// @onsubmit handler, so that the form does not navigate. So Create would run its
// handler and the dialog would stay open, with nothing logged.
//
// Handling it in C# is also the honest shape for a dialog holding a form: the
// work can fail, and a dialog that has already closed cannot show why.
private Task Save() => Ui.CloseModalAsync(Id, "save");
private Task Cancel() => Ui.CloseModalAsync(Id, "cancel");
}
The panel
The three rows on their own: a .modal is an ordinary bordered panel until
something puts it in the top layer.
Take over this order?
Automation stops handling ORD-4209 and you become the owner. Any pending reservation on it expires.
A note is written to the order's work log in your name.
<div class="modal">
<div class="modal-header">
<h3>Take over this order?</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body">
<p>Automation stops handling <strong>ORD-4209</strong> and you become the owner. Any pending reservation on it expires.</p>
<p class="form-hint">A note is written to the order's work log in your name.</p>
</div>
<div class="modal-footer">
<button class="btn" type="button">Cancel</button>
<button class="btn btn-warn-solid" type="button"><i class="ri-user-shared-line"></i> Accept & take over</button>
</div>
</div>
Destructive confirm
Name the consequence in the body, and make the confirming button the destructive one. Put
Cancel first in source order — showModal() focuses the first focusable element,
so the safe choice is the one that takes focus. A dialog that is its own component and hands
back a typed result is ISednaOverlays.
Delete this warehouse?
EU-West will be removed from every automatic reservation.
<div class="modal" style="max-width:420px">
<div class="modal-header">
<h3>Delete this warehouse?</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body">
<div class="alert alert-danger" style="margin:0">
<i class="ri-error-warning-line"></i>
<span>This cannot be undone. 14 open orders currently route here.</span>
</div>
<p><strong>EU-West</strong> will be removed from every automatic reservation.</p>
</div>
<div class="modal-footer">
<button class="btn" type="button">Keep it</button>
<button class="btn btn-danger" type="button"><i class="ri-delete-bin-line"></i> Delete</button>
</div>
</div>
A confirmation, from C#
Live — open it. A confirmation is a .modal-sm you write, not
something the library draws, so the question, the button labels and the language are
yours. Wrap it in <form method="dialog"> with a value on each
button and compare what ShowModalAsync returns with the one value that means
yes: Escape comes back as null. Leave @onsubmit off
this form — a Blazor submit handler cancels the submit, and closing the dialog is that
submit's default action.
@inject ISednaUi Ui
<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
<button class="btn btn-danger" type="button" @onclick="Discard">
<i class="ri-delete-bin-line"></i> Discard the reservation
</button>
<span class="badge">@_outcome</span>
</div>
@* The confirmation is ordinary markup: its words, its buttons and their order are the
app's. Each button's value becomes the dialog's returnValue, and the safe choice comes
first in source order, which is the one showModal() focuses. *@
<dialog id="discard-reservation" class="modal modal-sm" aria-labelledby="discard-reservation-title">
<form method="dialog">
<div class="modal-header">
<h3 id="discard-reservation-title">Discard the reservation?</h3>
</div>
<div class="modal-body">
<p>The five lines on ORD-4209 go back into stock. This cannot be undone.</p>
</div>
<div class="modal-footer">
<button class="btn" value="keep">Keep it</button>
<button class="btn btn-danger" value="discard"><i class="ri-delete-bin-line"></i> Discard</button>
</div>
</form>
</dialog>
@code {
private string _outcome = "nothing yet";
// Completes when the dialog closes, however it closed. Escape comes back as null,
// so anything but the one value that means yes is a no.
private async Task Discard()
{
var answer = await Ui.ShowModalAsync("discard-reservation");
_outcome = answer == "discard" ? "discarded" : "kept";
}
}
Scrolling body
The body scrolls on its own; there is nothing to add. The cap is
--modal-max-height, calc(100dvh - 2 * var(--space-8)) by default
and 240px here so the demo fits the page. One thing is still markup: a body
that scrolls and holds nothing focusable needs tabindex="0" with a
role and a name, as this one has, so a keyboard reader can scroll it.
Change history
<div class="modal" style="--modal-max-height: 240px">
<div class="modal-header">
<h3>Change history</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body" tabindex="0" role="group" aria-label="Change history">
<div class="kv"><span class="k">03:15</span><span class="v">Reconciliation ran</span></div>
<div class="kv"><span class="k">02:58</span><span class="v">Priority raised to P2</span></div>
<div class="kv"><span class="k">02:41</span><span class="v">Rerouted to EU-West</span></div>
<div class="kv"><span class="k">01:12</span><span class="v">Comment added by customer</span></div>
<div class="kv"><span class="k">00:47</span><span class="v">Reservation created automatically</span></div>
<div class="kv"><span class="k">00:31</span><span class="v">Order opened</span></div>
<div class="kv"><span class="k">00:30</span><span class="v">E-mail received</span></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="button">Close</button>
</div>
</div>
Widths
.modal-sm is 360px and .modal-lg is 760px, against the default
480px. Pick by how wide the content has to be, not by how important the dialog is.
Release the lock on ORD-4199?
Order detail
A wider panel, for content that would wrap badly at 480px.
<div class="sedna-col">
<div class="modal modal-sm">
<div class="modal-body"><p>Release the lock on ORD-4199?</p></div>
<div class="modal-footer">
<button class="btn" type="button">Cancel</button>
<button class="btn btn-primary" type="button">Release</button>
</div>
</div>
<div class="modal modal-lg">
<div class="modal-header">
<h3>Order detail</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body"><p>A wider panel, for content that would wrap badly at 480px.</p></div>
</div>
</div>
A first-run deck
A short sequence of slides in a modal, with progress dots and Back/Next — the other half of
first-run beside spotlight, which needs a page the reader has
already seen. Sequencing stays yours, and the step element has to be keyed on the
step number, or Blazor reuses the node and the entrance animation stops after slide
one; --deck-height fixes the body's height so the footer does not jump between
slides. Make each dot a <button> that jumps to its step, mark the current
one with aria-current="step", which is what
.deck-dot--active follows, and put the position in words as well — a row of
circles is not announced as one.
Welcome to the dispatch console
Two lanes, and they never meet
Everything the agent could decide on its own is already gone. What is left is the work that asked for a person — and the work nobody has claimed yet.
<div class="modal deck">
<div class="modal-header">
<h3>Welcome to the dispatch console</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<!-- One slide is rendered at a time. In Blazor this element carries a key
bound to the step number, so the framework replaces it rather than
patching it — which is what makes the entrance animation run again. -->
<div class="deck-body">
<div class="deck-step">
<h4 style="margin:0 0 8px">Two lanes, and they never meet</h4>
<p style="margin:0">
Everything the agent could decide on its own is already gone. What is left is
the work that asked for a person — and the work nobody has claimed yet.
</p>
</div>
</div>
<div class="deck-footer">
<!-- Each dot jumps to its step, so a reader who has understood slide one can go
back to it without pressing Back three times. aria-current marks the one
showing; the group's label carries the position in words, which a row of
circles cannot. -->
<div class="deck-dots" role="group" aria-label="Step 2 of 4">
<button class="deck-dot" type="button" aria-label="Go to step 1"></button>
<button class="deck-dot" type="button" aria-label="Go to step 2" aria-current="step"></button>
<button class="deck-dot" type="button" aria-label="Go to step 3"></button>
<button class="deck-dot" type="button" aria-label="Go to step 4"></button>
</div>
<div class="btn-group">
<button class="btn" type="button">Back</button>
<button class="btn btn-primary" type="button">Next</button>
</div>
</div>
</div>
Without a dialog
.modal-backdrop dims and centres a panel inside the page instead, at z-index
500, for markup that cannot be a <dialog> — you are already inside a form
and cannot nest another, or the panel has to stay in the page's own stacking context. Then
role="dialog", aria-modal="true", Escape, keeping
Tab inside the panel, making the rest of the page unreachable and putting focus
back where it came from are all on you. A drawer sits below at
480/490, so a modal opened from inside one still covers it.
<!-- Only when the panel cannot be a <dialog> — you are already inside a form and
cannot nest another, or the panel has to stay in the page's own stacking
context. Everything showModal() gives you has to be written by hand here:
Escape, the focus trap, inert content behind, and returning focus on close. -->
<div class="modal-backdrop">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="lock-title">
<div class="modal-header">
<h3 id="lock-title">Release the lock?</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body">
<p>ORD-4199 goes back to the queue.</p>
</div>
<div class="modal-footer">
<button class="btn" type="button">Cancel</button>
<button class="btn btn-primary" type="button">Release</button>
</div>
</div>
</div>