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 few lines of this page's own markup — a confirmation, a one-field
prompt, reading and writing this page's fields: write the
<dialog class="modal" id="…">in the page and awaitISednaUi.ShowModalAsync(id). See Opening one from C#. - Its own
.razorfile — its own parameters, state, loading or validation, or opened from more than one page: present it withISednaOverlays.ShowAsync<TComponent, TResult>, with one<SednaOverlayHost />in the layout. See A modal that is its own component.
@ref:
it is rendered on every visit whether it is opened or not, its state outlives each opening,
and every page that uses it repeats the wiring the host already does.
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 btn--solid" type="button"
onclick="document.getElementById('takeover').showModal()">
<i class="ri-user-shared-line"></i> <span>Take over this order</span>
</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 btn--solid" value="accept"><i class="ri-user-shared-line"></i> <span>Accept & take over</span></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.
This route is for a dialog that is part of the page's own markup; a dialog with a file of its
own is the next example.
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--brand btn--solid" 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--brand btn--solid" 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");
}
A modal that is its own component
Live — open it. An edit form, a wizard, anything with its own parameters,
state or validation is a component of its own, and
ISednaOverlays.ShowAsync<TComponent, TResult> presents it: it renders the
component into the one <SednaOverlayHost /> in your layout, opens the
<dialog class="modal"> the component wrote, and completes with the typed
result it closed with — Overlay.CloseAsync(result), or null for
Escape and Overlay.CancelAsync(). The page that opens it renders
nothing for it, and the component is removed when it closes, so every opening starts fresh.
The component takes a cascading SednaOverlay, puts its Id on the
<dialog>, and renders that dialog on its first render. The same call
presents a drawer or a sheet.
https://hooks.example.com/orders
Order events@inject ISednaOverlays Overlays
<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
<button class="btn" type="button" @onclick="Edit">
<i class="ri-edit-line"></i> <span>Edit webhook</span>
</button>
<code>@_webhook.Url</code>
<span class="badge">@_webhook.Events</span>
</div>
@code {
private EditWebhook.Settings _webhook = new("https://hooks.example.com/orders", "Order events");
// EditWebhook is its own component and writes its own <dialog class="modal">. This
// page renders nothing for it: the host in the layout renders it when asked, and it
// is gone again once it closes, with the typed result or null.
private async Task Edit()
{
var saved = await Overlays.ShowAsync<EditWebhook, EditWebhook.Settings>(
new() { [nameof(EditWebhook.Current)] = _webhook });
if (saved is not null) _webhook = saved;
}
}
@* The app's own modal component. Everything on screen is written here, and
ISednaOverlays only presents it; SednaOverlayHost sits once in the layout. *@
<dialog id="@Overlay.Id" class="modal" aria-labelledby="@($"{Overlay.Id}-title")">
<form @onsubmit="Save">
<div class="modal-header">
<h3 id="@($"{Overlay.Id}-title")">Edit webhook</h3>
<button class="modal-close" type="button" aria-label="Close" @onclick="Overlay.CancelAsync">
<i class="ri-close-line"></i>
</button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label" for="@($"{Overlay.Id}-url")">Address</label>
<input class="form-input" id="@($"{Overlay.Id}-url")" type="url" @bind="_url" />
</div>
<div class="form-field">
<label class="form-label" for="@($"{Overlay.Id}-events")">Send</label>
<select class="form-input form-select" id="@($"{Overlay.Id}-events")" @bind="_events">
<option>Order events</option>
<option>Payment events</option>
<option>All events</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn" type="button" @onclick="Overlay.CancelAsync">Cancel</button>
<button class="btn btn--brand btn--solid" type="submit">Save</button>
</div>
</form>
</dialog>
@code {
public sealed record Settings(string Url, string Events);
private string _url = "";
private string _events = "";
[CascadingParameter] public SednaOverlay Overlay { get; set; } = default!;
[Parameter] public Settings Current { get; set; } = default!;
protected override void OnInitialized() => (_url, _events) = (Current.Url, Current.Events);
private Task Save() => Overlay.CloseAsync(new Settings(_url, _events));
}
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 btn--solid" type="button"><i class="ri-user-shared-line"></i> <span>Accept & take over</span></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> <span>Delete</span></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> <span>Discard the reservation</span>
</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> <span>Discard</span></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";
}
}
Asking before it closes
Live — write something, then press Escape.
data-close-guard stops Escape closing a dialog that holds unsaved
work — and a swipe down on a .sheet — and raises cancel instead.
@oncancel asks, then calls Overlay.CancelAsync(); point the close
button and Cancel at the same handler. It holds however often Escape is
pressed, which a dialog refusing cancel on its own does not: the browser gives
up on the refusal after a few presses in a row.
@inject ISednaOverlays Overlays
<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
<button class="btn" type="button" @onclick="Reply">
<i class="ri-reply-line"></i> <span>Reply on ORD-4209</span>
</button>
<span class="badge">@_outcome</span>
</div>
@code {
private string _outcome = "nothing yet";
// ReplyToOrder asks before it closes with a message still unsent, so null here
// means nothing was written or the reader chose to discard it.
private async Task Reply()
{
var message = await Overlays.ShowAsync<ReplyToOrder, string>();
_outcome = message is null ? "not sent" : "sent";
}
}
@* The app's own overlay component, holding work that is lost if it closes. Escape, the
close button and Cancel all end up in Dismiss: data-close-guard stops Escape closing
the dialog and raises cancel instead, so nothing closes until Dismiss says so. *@
@inject ISednaUi Ui
<dialog id="@Overlay.Id" class="modal" aria-labelledby="@($"{Overlay.Id}-title")"
data-close-guard @oncancel="Dismiss">
<div class="modal-header">
<h3 id="@($"{Overlay.Id}-title")">Reply on ORD-4209</h3>
<button class="modal-close" type="button" aria-label="Close" @onclick="Dismiss">
<i class="ri-close-line"></i>
</button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label" for="@($"{Overlay.Id}-message")">Message to Alex Fischer</label>
<textarea class="form-input" id="@($"{Overlay.Id}-message")" rows="4"
@bind="_message" @bind:event="oninput"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn" type="button" @onclick="Dismiss">Cancel</button>
<button class="btn btn--brand btn--solid" type="button" disabled="@IsEmpty"
@onclick="() => Overlay.CloseAsync(_message)">
<i class="ri-send-plane-line"></i> <span>Send</span>
</button>
</div>
</dialog>
@* The question is the app's markup too, opened over the reply. The safe choice comes
first, which is the one showModal() focuses. *@
<dialog id="@($"{Overlay.Id}-discard")" class="modal modal--sm" aria-labelledby="@($"{Overlay.Id}-discard-title")">
<form method="dialog">
<div class="modal-header">
<h3 id="@($"{Overlay.Id}-discard-title")">Discard this reply?</h3>
</div>
<div class="modal-body">
<p>It has not been sent, and what you wrote is lost.</p>
</div>
<div class="modal-footer">
<button class="btn" value="keep">Keep writing</button>
<button class="btn btn--danger" value="discard">Discard</button>
</div>
</form>
</dialog>
@code {
private string _message = "";
[CascadingParameter] public SednaOverlay Overlay { get; set; } = default!;
private bool IsEmpty => string.IsNullOrWhiteSpace(_message);
// Nothing written closes at once; anything else asks first.
private async Task Dismiss()
{
if (IsEmpty || await Ui.ShowModalAsync($"{Overlay.Id}-discard") == "discard")
await Overlay.CancelAsync();
}
}
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--brand btn--solid" type="button">Close</button>
</div>
</div>
Widths
.modal--sm is 360px, .modal--lg 760px and .modal--xl 1140px,
against the default 480px; .modal--full takes the window's width less a margin at
each side and the safe-area insets. Pick by how wide the content has to be, not by how
important the dialog is. Any other width is --modal-width on the dialog —
style="--modal-width: min(1200px, 96vw)"; the panel never grows past the window.
Release the lock on ORD-4199?
Order detail
A wider panel, for content that would wrap badly at 480px.
Compare revisions
Two columns side by side: a diff, a wide table, a form beside its preview.
Sequence editor
As wide as the window allows, with a margin at each side.
<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--brand btn--solid" 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 class="modal modal--xl">
<div class="modal-header">
<h3>Compare revisions</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body"><p>Two columns side by side: a diff, a wide table, a form beside its preview.</p></div>
</div>
<div class="modal modal--full">
<div class="modal-header">
<h3>Sequence editor</h3>
<button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
</div>
<div class="modal-body"><p>As wide as the window allows, with a margin at each side.</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--brand btn--solid" type="button">Next</button>
</div>
</div>
</div>
Dots that only show the position
Where the dots are not controls — a setup the reader cannot skip through — they are
plain <span class="deck-dot">s and .deck-dot--active marks the
current one, with the position in the group's accessible name.
<div class="sedna-row sedna-gap-3">
<div class="deck-dots" role="img" aria-label="Setting up: step 2 of 4">
<span class="deck-dot"></span>
<span class="deck-dot deck-dot--active"></span>
<span class="deck-dot"></span>
<span class="deck-dot"></span>
</div>
<span class="text-muted text-sm">Connecting the warehouse</span>
</div>
Closed by a click beside it
closedby="any" on the <dialog> closes it on a click on the
backdrop, as Escape does — the platform's own light dismiss, in every browser
this library supports, with nothing to script and no target === dialog test
to get wrong. Keep it for a dialog that holds nothing to lose; with
data-close-guard the click is refused like any other close request.
<!-- closedby="any": a click on the backdrop closes it, as Escape does. Leave it off a
dialog that holds unsaved work, or add data-close-guard, which refuses this close too. -->
<dialog id="shortcuts" class="modal" closedby="any" aria-labelledby="shortcuts-title">
<div class="modal-header">
<h3 id="shortcuts-title">Keyboard shortcuts</h3>
<form method="dialog"><button class="modal-close" aria-label="Close"><i class="ri-close-line"></i></button></form>
</div>
<div class="modal-body">
<p><kbd>Ctrl</kbd> <kbd>K</kbd> opens the command palette.</p>
</div>
</dialog>
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--brand btn--solid" type="button">Release</button>
</div>
</div>
</div>