Toasts
tier 3 — a surfaceA confirmation that appears, says what happened, and goes away. Anything the reader still has to act on is an alert, which stays until the state changes.
Brand and secret
Beside go, warn, danger and info, a toast takes brand and secret, from script as kind too.
<div class="toast-stack" role="status" aria-live="polite" style="position:static; inset:auto;">
<div class="toast toast--brand">
<i class="ri-megaphone-line"></i>
<span class="toast-body"><strong>Published</strong>The release notes are live.</span>
<button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
</div>
<div class="toast toast--secret">
<i class="ri-lock-line"></i>
<span class="toast-body"><strong>Key copied</strong>It is shown once; keep it safe.</span>
<button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
</div>
</div>
Raising one
Live. sednaUi.toast(message, options) renders the stack and the
toast, stacks a second under the first, and removes it after its timeout;
timeout: 0 keeps it until dismissed, which is what a failure wants. From C# it
is ToastAsync. kind is go, warn,
danger or info, and info is the default.
<button class="btn btn--go btn--solid" type="button"
onclick="sednaUi.toast('ORD-4182 is on its way to EU-West.', { kind: 'go', title: 'Order dispatched' })">
Dispatch
</button>
<button class="btn btn--warn" type="button"
onclick="sednaUi.toast('Two of five lines are awaiting stock.', { kind: 'warn', title: 'Partially fulfilled' })">
Warn
</button>
<button class="btn btn--danger" type="button"
onclick="sednaUi.toast('The endpoint returned 503. Nothing was written.', { kind: 'danger', title: 'Import failed', timeout: 0 })">
Fail, stays until dismissed
</button>
<button class="btn" type="button"
onclick="sednaUi.toast('Nightly reconciliation closed 4 stale orders.')">
Info
</button>
Work that ends later
Live — upload it. ToastAsync returns a
SednaToast: show Uploading… with timeoutMs: 0, and when the
work ends ReplaceAsync the outcome into the same toast, in place.
DismissAsync removes it early. A toast the reader already closed is not an error:
replacing it shows a new one, because the outcome still has to reach them. The close button's
name is SednaUiOptions.ToastDismissLabel — the one word the library writes
itself — or dismissLabel for one toast.
@inject ISednaUi Ui
<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
<button class="btn" type="button" disabled="@_busy" @onclick="Upload">
<i class="ri-upload-2-line"></i> <span>Upload the packing list</span>
</button>
<label class="form-check"><input type="checkbox" @bind="_fail" /> <span>Make it fail</span></label>
</div>
@code {
private bool _busy;
private bool _fail;
// One toast for the whole piece of work: it says "uploading" while it runs and is
// replaced in place with the outcome, rather than one toast leaving and another arriving.
private async Task Upload()
{
_busy = true;
var toast = await Ui.ToastAsync("Uploading packing-list-4209.pdf…", timeoutMs: 0, dismissible: false);
await Task.Delay(1500); // the work
if (_fail)
await toast.ReplaceAsync("The warehouse did not answer. Nothing was uploaded.", ToastKind.Danger, timeoutMs: 0);
else
await toast.ReplaceAsync("packing-list-4209.pdf is attached to ORD-4209.", ToastKind.Go, title: "Uploaded");
_busy = false;
}
}
A toast you write
A notification that arrives while the reader is elsewhere — a task assigned, a value to
check — is a toast with something to author: a body that opens the record, buttons of its
own. Write it once in a <template> and present it in the library's stack, so it
has the top layer above an open modal, the hold on its timer and one corner shared with every
other toast. Never position a second stack of your own: it collides with this one and loses all
three.
A notification card
Live. sednaUi.toast.present(templateId, data, options) clones
the template's first element into the stack and fills its data-* slots with text
from data — title fills [data-title] — and
href becomes the href of its [data-href] link. A body
that is the thing to click is an a.toast-body or a
button.toast-body, so Tab reaches it apart from the close button;
.toast-actions holds buttons under the text. A control with
data-toast-value ends the toast with that value, which
sednaUi.toast.result(id) resolves with; a .toast-close ends it with
null, as a dismissal or the timeout do. A link still navigates.
<!-- The cards, written once as templates. Every element and every word is yours. -->
<template id="toast-assigned">
<div class="toast toast--info">
<i class="ri-user-received-line" aria-hidden="true"></i>
<a class="toast-body" data-href data-toast-value="open">
<strong data-title></strong><span data-text></span>
</a>
<button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
</div>
</template>
<template id="toast-review">
<div class="toast toast--warn">
<i class="ri-alert-line" aria-hidden="true"></i>
<div class="toast-body">
<strong data-title></strong><span data-text></span>
<div class="toast-actions">
<button class="btn btn--sm btn--warn btn--solid" type="button" data-toast-value="review">Review now</button>
<button class="btn btn--sm" type="button" data-toast-value="later">Later</button>
</div>
</div>
<button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
</div>
</template>
<div class="sedna-col sedna-gap-4" style="max-width:420px">
<div class="sedna-row-wrap sedna-gap-3">
<button class="btn" type="button"
onclick="sednaUi.toast.present('toast-assigned', { title: 'Assigned to you', text: 'ORD-4182 · Northwind Retail, by Alex Fischer', href: '/toast' }, { timeout: 0 })">
<i class="ri-user-received-line"></i> <span>A task arrives</span>
</button>
<button class="btn" type="button"
onclick="sednaUi.toast.result(sednaUi.toast.present('toast-review', { title: 'Retention set to 400 days', text: 'Saved. Usually it is 30 to 365.' }, { timeout: 0 })).then(v => { if (v === 'review') sednaUi.toast('Opened the retention setting.', { kind: 'go' }); })">
<i class="ri-alert-line"></i> <span>A value to check</span>
</button>
</div>
<!-- What they look like, for reading before a script runs. -->
<div class="toast-stack" style="position:static; inset:auto">
<div class="toast toast--info">
<i class="ri-user-received-line" aria-hidden="true"></i>
<a class="toast-body" href="#"><strong>Assigned to you</strong><span>ORD-4182 · Northwind Retail, by Alex Fischer</span></a>
<button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
</div>
<div class="toast toast--warn">
<i class="ri-alert-line" aria-hidden="true"></i>
<div class="toast-body">
<strong>Retention set to 400 days</strong><span>Saved. Usually it is 30 to 365.</span>
<div class="toast-actions">
<button class="btn btn--sm btn--warn btn--solid" type="button">Review now</button>
<button class="btn btn--sm" type="button">Later</button>
</div>
</div>
<button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
</div>
</div>
</div>
Notifications from C#, with +N more
Live — press it five times. PresentToastAsync returns a
SednaToast, and ResultAsync waits for what the reader did with it
— "open", "read", or null — for as long as
that takes, the way ShowModalAsync waits for a dialog. Three cards show at once;
the rest fold into one card whose count ReplaceFromTemplateAsync moves in place,
keeping its position and the wait already on it. A template that is not there answers
null rather than throwing into the circuit.
@inject ISednaUi Ui
<template id="note-card">
<div class="toast toast--info">
<i class="ri-notification-3-line" aria-hidden="true"></i>
<div class="toast-body">
<strong data-title></strong><span data-text></span>
<div class="toast-actions">
<button class="btn btn--sm btn--brand btn--solid" type="button" data-toast-value="open">Open</button>
<button class="btn btn--sm" type="button" data-toast-value="read">Mark as read</button>
</div>
</div>
<button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
</div>
</template>
<template id="note-more">
<div class="toast">
<i class="ri-stack-line" aria-hidden="true"></i>
<button class="toast-body" type="button" data-toast-value="all"><strong data-count></strong><span>Show every notification</span></button>
</div>
</template>
<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
<button class="btn" type="button" @onclick="Arrive"><i class="ri-notification-3-line"></i> <span>A notification arrives</span></button>
<span class="text-muted" role="status">@_status</span>
</div>
@code {
private const int Shown = 3;
private readonly Queue<string> _waiting = new();
private int _onScreen;
private SednaToast? _more;
private int _next = 4182;
private string _status = "Press it a few times.";
private async Task Arrive()
{
var order = $"ORD-{_next++}";
if (_onScreen < Shown) await Show(order);
else
{
_waiting.Enqueue(order);
await UpdateMore();
}
}
// One card per notification, up to three; each waits for what the reader does with it.
private async Task Show(string order)
{
var card = await Ui.PresentToastAsync("note-card",
new Dictionary<string, string?> { ["title"] = "Assigned to you", ["text"] = $"{order} · Northwind Retail" }, timeoutMs: 0);
if (card is null) return;
_onScreen++;
_ = Handle(order, card);
}
private async Task Handle(string order, SednaToast card)
{
var choice = await card.ResultAsync();
_status = choice switch
{
"open" => $"Opened {order}.",
"read" => $"{order} marked as read.",
_ => $"{order} put away.",
};
_onScreen--;
if (_waiting.TryDequeue(out var next)) await Show(next);
await UpdateMore();
await InvokeAsync(StateHasChanged);
}
// The rest fold into one card that counts them, replaced in place as the count moves.
private async Task UpdateMore()
{
if (_waiting.Count == 0)
{
if (_more is not null) await _more.DismissAsync();
_more = null;
return;
}
var data = new Dictionary<string, string?> { ["count"] = $"+{_waiting.Count} more" };
if (_more is null)
{
_more = await Ui.PresentToastAsync("note-more", data, timeoutMs: 0);
if (_more is not null) _ = ShowAll(_more);
}
else await _more.ReplaceFromTemplateAsync("note-more", data, timeoutMs: 0);
}
private async Task ShowAll(SednaToast more)
{
if (await more.ResultAsync() != "all") return;
_status = $"Showing all {_waiting.Count} waiting notifications.";
_waiting.Clear();
_more = null;
await InvokeAsync(StateHasChanged);
}
}
The markup
You do not write this for sednaUi.toast, and a toast you write goes in a
<template> for toast.present. It is here for an app that
renders a stack inside its own page, such as a server-rendered one. The stack carries the live region, and on this path it is yours to
write: role="status" plus aria-live="polite" on
.toast-stack, or a toast an app rendered is never announced at all. Do not put
role="alert" on an individual toast — two live regions announce the same text
twice, and a failure that has to interrupt gets aria-live="assertive" on the
stack while it is showing.
<div class="toast-stack" role="status" aria-live="polite" style="position:static; inset:auto;">
<div class="toast toast--go">
<i class="ri-check-line"></i>
<span class="toast-body"><strong>Order dispatched</strong>ORD-4182 is on its way to EU-West.</span>
<button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
</div>
<div class="toast toast--warn">
<i class="ri-alert-line"></i>
<span class="toast-body"><strong>Partially fulfilled</strong>Two of five lines are awaiting stock.</span>
<button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
</div>
<div class="toast toast--danger">
<i class="ri-error-warning-line"></i>
<span class="toast-body"><strong>Import failed</strong>The endpoint returned 503. Nothing was written.</span>
<button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
</div>
<div class="toast toast--info">
<i class="ri-information-line"></i>
<span class="toast-body">Nightly reconciliation closed 4 stale orders.</span>
<button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
</div>
</div>
Toast or alert
The same event, told both ways. A toast is a receipt for something that already happened and needs nothing from the reader; an alert stays until the state changes, and is where an action the reader must take belongs. A notification card can offer one, but a toast can be dismissed, so nothing that has to happen may depend on it.
Nothing to do — a toast, gone in a few seconds.
Something to do — an alert, until the state changes.
<div class="sedna-col sedna-gap-3" style="max-width:460px">
<div>
<p class="form-hint sedna-mb-1">Nothing to do — a toast, gone in a few seconds.</p>
<div class="toast-stack" style="position:static; inset:auto">
<div class="toast toast--go">
<i class="ri-check-line"></i>
<span class="toast-body"><strong>Order dispatched</strong>ORD-4182 is on its way to EU-West.</span>
<button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
</div>
</div>
</div>
<div>
<p class="form-hint sedna-mb-1">Something to do — an alert, until the state changes.</p>
<div class="alert alert--danger">
<i class="ri-error-warning-line"></i>
<span>The import wrote nothing. 41 of 128 rows were rejected.</span>
<button class="btn btn--sm sedna-push" type="button">Review the rejects</button>
</div>
</div>
</div>
The API
The JavaScript call returns a function that removes that toast early, whose
id also names it for toast.dismiss(id) and
toast.replace(id, message, options). toast.present and
toast.result are a toast you write and how it ended. dismissLabel names the close
button, and configure({ toastDismissLabel }) sets it once for every toast.
// JavaScript. Returns a function that removes this toast early.
const close = sednaUi.toast('ORD-4182 is on its way to EU-West.', {
kind: 'go', // 'go' | 'warn' | 'danger' | 'info', default neutral
title: 'Order dispatched',
timeout: 4000, // 0 stays until dismissed
dismissible: true, // false removes the close button
});
close(); // e.g. once the SignalR event confirms it
// C#. The remover cannot cross the JS boundary, so ToastAsync returns a handle
// that names the toast by its id: DismissAsync, ReplaceAsync.
var toast = await Ui.ToastAsync("ORD-4182 is on its way to EU-West.",
ToastKind.Go, title: "Order dispatched");
// A failure the reader has to see gets timeout: 0. It is announced through
// aria-live on the stack — assertive for danger, polite for everything else —
// so nothing steals focus from what is being typed.
await Ui.ToastAsync("The endpoint returned 503. Nothing was written.",
ToastKind.Danger, title: "Import failed", timeoutMs: 0);
// The stack is created on first use and found by data-sedna-toasts, so an app
// renders nothing and positions nothing. Only a stack the library created is
// ever appended to or removed.
// A toast you write: cloned from your <template>, its data-* slots filled as text,
// `href` on its [data-href] link. Returns the id, or 0 when there is no such template.
const id = sednaUi.toast.present('toast-assigned',
{ title: 'Assigned to you', text: 'ORD-4182', href: '/orders/4182' },
{ timeout: 0 }); // replace: <id> puts it in that toast's place
const choice = await sednaUi.toast.result(id); // 'open' — its data-toast-value — or null
// C#.
var card = await Ui.PresentToastAsync("toast-assigned",
new Dictionary<string, string?> { ["title"] = "Assigned to you", ["href"] = "/orders/4182" },
timeoutMs: 0);
if (card is not null && await card.ResultAsync() == "open") { /* mark it read */ }