Playground
The feedback channel for a CQRS app. The server renders what happened as a list of toasts; the region stacks them in a corner, announces them to assistive technology and dismisses them again on its own. Nothing is invented in the browser: the toasts are server state, and a morph brings the current list.
What belongs where
- The server owns the list.
toastsis the whole truth about which messages exist. Add one in a command, render the page again, and it appears. Drop it and it is gone, everywhere, in every tab. - The viewer owns the reading. Which toasts this viewer has already closed, the countdowns, the pause while the pointer or the keyboard is inside, the entrance and exit animations: all local, in
$$signals and plain closure state, never written back to an attribute. A morph therefore never resurrects a toast that was just closed, and never resets a countdown. - No pending, no optimistic updates. A toast is the server's confirmation. It has no
confirmattribute, no:state(pending)and norevert(), because nothing here is an edit waiting to be accepted.
Closing a toast is view state, so it emits sb-dismiss and hides the toast at once. The server may listen and drop the toast from its list with a command. When an id disappears from toasts, the region forgets it completely, so a later toast that reuses the id is shown again.
Examples
Stacking, announcing, counting down
Local signals stand in for the server here, which is all a docs page needs. A real page hands toasts the list the server rendered.
<div data-signals="{_n: 0, _list: []}">
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-block-end: 12px">
<sb-button size="sm" data-on:click="$_n++; $_list = [...$_list, {id: 'i' + $_n, text: 'Telemetry packet ' + $_n + ' received.'}]">Info</sb-button>
<sb-button size="sm" variant="outline" data-on:click="$_n++; $_list = [...$_list, {id: 'i' + $_n, variant: 'ok', title: 'Saved', text: 'Mission plan stored.'}]">Success</sb-button>
<sb-button size="sm" variant="outline" data-on:click="$_n++; $_list = [...$_list, {id: 'i' + $_n, variant: 'warn', text: 'Fuel at 18%.'}]">Warning</sb-button>
<sb-button size="sm" variant="outline" data-on:click="$_n++; $_list = [...$_list, {id: 'i' + $_n, variant: 'danger', title: 'Hull breach', text: 'Seal deck 7 immediately.', duration: 0}]">Danger</sb-button>
</div>
<sb-toast
placement="inline"
max="3"
duration="6000"
data-attr:toasts="JSON.stringify($_list)"
data-preserve-attr="toasts"
data-on:sb-dismiss="$_list = $_list.filter(t => t.id !== evt.detail.id)"
></sb-toast>
</div>
Hover a toast, or tab into its dismiss button, and every countdown stops until you leave. The danger toast carries duration: 0, so it waits for a human.
In a corner
placement pins the stack to one of the six corners of the viewport; inline (used in these docs) puts it in the page flow instead. The newest toast always sits closest to the edge the region is pinned to, and the DOM order follows the visual order, so Tab and screen readers move the way the eye does.
<div data-signals="{_m: 0, _corner: []}">
<sb-button data-on:click="$_m++; $_corner = [...$_corner, {id: 'c' + $_m, variant: 'ok', title: 'Command accepted', text: 'Burn scheduled for T-minus 40.'}]">Send a command</sb-button>
<sb-toast
placement="bottom-end"
label="Mission notifications"
data-attr:toasts="JSON.stringify($_corner)"
data-preserve-attr="toasts"
data-on:sb-dismiss="$_corner = $_corner.filter(t => t.id !== evt.detail.id)"
></sb-toast>
</div>
With commands
In a CQRS app nothing about a toast is decided in the browser. A command runs, the page renders again, and the server's render contains the toast:
<sb-toast placement="top-end" max="4" duration="6000"
toasts='[{"id":"7f2","variant":"ok","title":"Saved","text":"Mission plan stored."}]'
data-on:sb-dismiss="@post('/cmd/toast-seen', {payload: {tabid: $tabid, ...evt.detail}})"></sb-toast>
The command that produced the message queues it for this session (in tab_state, say, next to the other server-owned UI state); the page renders the queue into toasts. sb-dismiss posts the id back, the command drops it from the queue, and the next render no longer contains it. The region does not wait for that round trip: the toast is hidden the moment it is dismissed, and the server's answer only has to agree.
If the page drives toasts from a signal instead of rendering the attribute, add data-preserve-attr="toasts", or the next morph resets the attribute to the server's markup:
<sb-toast data-attr:toasts="JSON.stringify($messages)" data-preserve-attr="toasts"></sb-toast>
Toasts
Each entry of toasts is {id, title?, text, variant?, duration?}:
id: stable and unique in the list. It is the only thing that ties a dismissal to a toast, so reuse it for the same message and never for a different one.title: an optional bold first line, in the tone colour.text: the message. A plain string in the array is read as{text}with a positional id.variant:info(the default),ok,warnordanger. It sets the tone colour and how the message is announced.duration: ms for this toast, overriding thedurationprop.0makes it sticky.
Only the newest max toasts are rendered, and only rendered toasts count down. Older ones wait, and take their turn when a newer one goes, unless the server has dropped them by then.
Methods
dismiss(id): dismiss one toast, exactly as the button does (includingsb-dismiss).clear(): dismiss everything on the stack.
Styling
Every part is styleable from the page: ::part(region), ::part(toast), ::part(title), ::part(text), ::part(close) and ::part(bar) (the remaining-time bar). Tones come from --sb-info, --sb-ok, --sb-warn and --sb-danger, the surface from --sb-surface-raised, and the stack sits at --sb-z-toast. Two component variables tune the geometry:
--sb-toast-width: width of the stack (default22rem; it never exceeds the viewport).--sb-toast-inset: distance from the edges (default1rem).
Corners notch with --sb-notch, so the 8-bit look can be turned off per theme, and the entrance, exit and countdown animations are dropped for prefers-reduced-motion: reduce (the bar then jumps rather than slides).
Accessibility
- Announcing is separate from the visible stack. The stack moves, fades and reorders, which a live region would read out again and again. So the toasts sit in a plain
role="region"withlabelas its accessible name, and two visually hidden live regions do the announcing: a politerole="status"forinfoandok, an assertiverole="alert"forwarnanddanger. An urgent message interrupts, an ordinary one waits for a pause. - Focus is never stolen. Nothing is focused when a toast appears. Each toast has a dismiss button named after its message ("Dismiss: Saved. Mission plan stored."), reachable with Tab in the order the toasts are shown.
- Time can be stopped. Every countdown pauses while the pointer is over the region or the keyboard focus is inside it, so a toast cannot vanish while it is being read or its button is being aimed at. A toast that must not disappear at all gets
duration: 0. - The countdown is decoration. The bar is
aria-hidden; it never carries information that is not in the text.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag. The autoloader imports each component the first time its tag appears, including tags added later by a Datastar morph.
<!-- Once per page: Datastar with Rocket, and the Starbase autoloader.
It loads every <sb-…> component the first time its tag appears. -->
<script type="importmap">
{ "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<!-- Optional, no flash of undefined elements: class="sb-cloak" on <html>, and -->
<style>.sb-cloak :not(:defined) { visibility: hidden }</style>
<sb-toast placement="inline" duration="0" label="Flight deck" style="inline-size: 100%" toasts='[{"id":"a","variant":"ok","title":"Docking complete","text":"All systems nominal."},{"id":"b","variant":"warn","text":"Fuel at 18%."}]'></sb-toast>
<!-- In production, pin today's catalog instead of the latest: the browser then
refuses any file that changed. Add "integrity" to the import map above: the
hashes from https://starbase.zweiundeins.gmbh/c/@722e189276ac/importmap.json and Datastar's, below. -->
<!--
<script type="importmap">
{ "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": { "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG", "…": "…from importmap.json" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@722e189276ac/autoloader.js" integrity="sha384-scFa7/4jztDOx1YZT9/1g1ULYm9LsDlQn/8hMTyyD4IkPnZV2pfhHytoNTWTJG6b"></script>
-->
<!-- Or load just this component, pinned to this version. The minified module
is what the autoloader uses; the readable source is the same URL without .min. -->
<!-- <script type="module" src="https://starbase.zweiundeins.gmbh/c/toast@369a4b600601/toast.min.js" integrity="sha384-N3TyWErIgNKszW0T5ZZ78j+7DWy4hVuGpOgROPVhzjwjiEa+rKTBWUW+45U7RawY"></script> -->Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
toast.js | 15.5 kB | 6.0 kB | 5.2 kB | 4.1 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
toasts | json | [] | The toasts, oldest first: [{id, title?, text, variant?: info|ok|warn|danger, duration?}]. Server state: every morph brings the current list, and a toast the server drops is gone. |
placement | "top-start" | "top-center" | "top-end" | "bottom-start" | "bottom-center" | "bottom-end" | "inline" | "bottom-end" | Corner of the viewport the stack is pinned to. "inline" puts it in the page flow instead (docs, demos, panels). |
max | number | 3 | How many toasts are shown at once. Older ones wait and appear as newer ones go; only shown toasts count down. |
duration | number | 5000 | Default time before a toast dismisses itself, in ms. 0 makes them sticky; a toast may carry its own duration. |
label | string | "Notifications" | Accessible name of the region. |
Events
| Name | Description |
|---|---|
sb-dismiss | A toast went away. detail: { id, reason: "user" | "timeout" }. It is already hidden locally; post a command if the server should drop it too. |