A block-level notification system. Mount a single <Toaster> near the root of the app and fire feedback from anywhere with the imperative toast() API. Toasts render at any of the six viewport corners, support promise tracking, swipe-to-dismiss, dedupe-by-id, and a built-in notification center.
Mount a <Toaster> once and fire toasts from button handlers (or any other code path). Each call returns the toast’s id, which you can pass to toast.dismiss() or toast.update() later. Six built-in types — primary, success, danger, warning, neutral, and loading — drive the icon and color palette.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm", direction: "row", wrap: "wrap" })}>
<Button data-toast-demo="primary">Primary</Button>
<Button data-toast-demo="success">Success</Button>
<Button data-toast-demo="danger">Danger</Button>
<Button data-toast-demo="warning">Warning</Button>
<Button data-toast-demo="neutral">Neutral</Button>
</div>
<Toaster position="bottom-right" />
<script>
import { toast } from "@pindoba/astro-toast/runtime";
document.querySelectorAll<HTMLElement>("[data-toast-demo]").forEach((btn) => {
btn.addEventListener("click", () => {
const type = btn.dataset.toastDemo;
if (type === "primary") {
toast.primary({
title: "New mention",
content: "Jane tagged you in a doc.",
});
} else if (type === "success") {
toast.success({ title: "Saved", content: "Your changes are live." });
} else if (type === "danger") {
toast.danger({
title: "Couldn't save",
content: "Network is unreachable.",
});
} else if (type === "warning") {
toast.warning({
title: "Heads up",
content: "Disk is almost full.",
});
} else if (type === "neutral") {
toast.neutral({ title: "FYI", content: "Cache rebuilt." });
}
});
});
</script>Toasts default to secondary emphasis — a calm, tinted Alert variant that doesn’t fight the rest of the page. Pass emphasis: "primary" for can’t-miss messages (saturated filled card), or emphasis: "tertiary" for the quietest variant. Set defaultEmphasis on <Toaster> to change the global default.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm", direction: "row", wrap: "wrap" })}>
<Button data-emphasis-demo="default">Default (secondary)</Button>
<Button data-emphasis-demo="primary">Primary (loud)</Button>
<Button data-emphasis-demo="tertiary">Tertiary (quietest)</Button>
</div>
<Toaster position="bottom-right" />
<script>
import { toast } from "@pindoba/astro-toast/runtime";
document
.querySelectorAll<HTMLElement>("[data-emphasis-demo]")
.forEach((btn) => {
btn.addEventListener("click", () => {
const variant = btn.dataset.emphasisDemo;
if (variant === "default") {
toast.success({
title: "Saved",
content: "Default toasts use secondary emphasis.",
});
} else if (variant === "primary") {
toast.danger({
title: "Outage detected",
content: "Reserve primary for can't-miss messages.",
emphasis: "primary",
});
} else if (variant === "tertiary") {
toast.neutral({
title: "Heads up",
content: "Tertiary is the quietest variant.",
emphasis: "tertiary",
});
}
});
});
</script>toast.promise() shows a sticky loading toast, then transitions in-place to a success or error toast based on the promise’s resolution. The DOM node is swapped so the new feedback color, icon, and content all animate cleanly without unmount. Only the resolved state lands in history — loading toasts are transient and never recorded.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm", direction: "row" })}>
<Button data-toast-demo="promise-resolve">Resolve in 1.5s</Button>
<Button data-toast-demo="promise-reject">Reject in 1.5s</Button>
</div>
<Toaster position="bottom-right" />
<script>
import { toast } from "@pindoba/astro-toast/runtime";
document.querySelectorAll<HTMLElement>("[data-toast-demo]").forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.dataset.toastDemo;
if (action === "promise-resolve") {
toast.promise(
new Promise<string>((res) => setTimeout(() => res("ok"), 1500)),
{
loading: "Uploading…",
success: "Uploaded",
error: "Upload failed",
},
);
} else if (action === "promise-reject") {
toast
.promise(
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error("503")), 1500),
),
{
loading: "Uploading…",
success: "Uploaded",
error: "Upload failed — try again",
},
)
.catch(() => {
/* swallow — toast already showed */
});
}
});
});
</script>Add an action to render a secondary button inline next to the dismiss control. Use it for quick recoveries like undo, retry, or open-in-new-tab.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm", direction: "row" })}>
<Button data-toast-action="with-action">With action button</Button>
<Button data-toast-action="no-title">Title-less</Button>
</div>
<Toaster position="bottom-right" />
<script>
import { toast } from "@pindoba/astro-toast/runtime";
document
.querySelectorAll<HTMLElement>("[data-toast-action]")
.forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.dataset.toastAction;
if (action === "with-action") {
toast.neutral({
title: "Email archived",
content: "Tap undo to restore it.",
action: {
label: "Undo",
onClick: () => toast.success({ title: "Restored" }),
},
});
} else if (action === "no-title") {
toast.warning({
content:
"Title-less toasts use the row layout, like a compact alert.",
});
}
});
});
</script>Calling toast() with an existing id reuses the active row instead of stacking a new one. The count badge updates, the timer resets, and the toast pops to the front of the deck. Deduped occurrences are also recorded in the history with a single entry whose count and timestamps accumulate.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm", direction: "row" })}>
<Button data-toast-dedupe>Fire same id</Button>
</div>
<Toaster position="bottom-right" />
<script>
import { toast } from "@pindoba/astro-toast/runtime";
document
.querySelector("[data-toast-dedupe]")
?.addEventListener("click", () => {
toast.success({
id: "dedupe-demo",
title: "Saved",
content:
"Same id — count badge ticks up and the toast pops to the front.",
});
});
</script>A toast can override the toaster’s default position to land in a different viewport corner — useful for forcing a critical error to top-center while everything else flows from bottom-right.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { grid } from "@pindoba/styled-system/patterns";
---
<div class={grid({ columns: 3, gap: "sm" })}>
<Button data-toast-position="top-left">Top left</Button>
<Button data-toast-position="top-center">Top center</Button>
<Button data-toast-position="top-right">Top right</Button>
<Button data-toast-position="bottom-left">Bottom left</Button>
<Button data-toast-position="bottom-center">Bottom center</Button>
<Button data-toast-position="bottom-right">Bottom right</Button>
</div>
<Toaster position="bottom-right" />
<script>
import { toast } from "@pindoba/astro-toast/runtime";
import type { ToastPosition } from "@pindoba/astro-toast/runtime";
document
.querySelectorAll<HTMLElement>("[data-toast-position]")
.forEach((btn) => {
btn.addEventListener("click", () => {
const position = btn.dataset.toastPosition as ToastPosition;
toast.neutral({
title: position,
content: "Per-toast position override.",
position,
});
});
});
</script>Set stacked to render multiple toasts as an overlapping deck. With expandOnHover, hovering or focusing the deck fans the rows out vertically so the user can read and interact with all of them.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm", direction: "row" })}>
<Button data-toast-spawn>Spawn random toast</Button>
</div>
<Toaster position="bottom-right" stacked={true} expandOnHover={true} />
<script>
import { toast, getToastStore } from "@pindoba/astro-toast/runtime";
document
.querySelector("[data-toast-spawn]")
?.addEventListener("click", () => {
// Reapply the demo's Toaster config — needed when several demos share
// one document and the user wants to see this demo's settings now.
getToastStore().setConfig({ stacked: true, expandOnHover: true });
const types = ["success", "danger", "warning", "neutral"] as const;
const type = types[Math.floor(Math.random() * types.length)];
toast[type]({
title: type,
content: `Spawned at ${new Date().toLocaleTimeString()}`,
});
});
</script>Set enableHistory to keep dismissed toasts in a log. Pair with storageKey to persist across reloads. The built-in dialog is rendered for you — call toggleCenter() from the useToastHistory() composable (Svelte) or import from runtime (Astro) to open it. The dialog filters by All / Unread.
No notifications.
---
import Button from "@pindoba/astro-button";
import Toaster from "../Toaster.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm", direction: "row" })}>
<Button data-toast-history="push">Push toast</Button>
<Button data-toast-history="open">
Open notifications (<span data-history-unread-count>0</span>)
</Button>
<Button data-toast-history="mark-read">Mark all read</Button>
<Button data-toast-history="clear">Clear history</Button>
</div>
<Toaster
position="bottom-right"
enableHistory={true}
storageKey="pindoba-toast-demo"
/>
<script>
import {
toast,
toggleCenter,
markAllAsRead,
clearHistory,
$unreadCount,
} from "@pindoba/astro-toast/runtime";
$unreadCount.subscribe((count) => {
document
.querySelectorAll<HTMLElement>("[data-history-unread-count]")
.forEach((el) => (el.textContent = String(count)));
});
type DemoToast = {
type: "primary" | "success" | "danger" | "warning" | "neutral";
title: string;
content: string;
};
const samples: DemoToast[] = [
{ type: "success", title: "Saved", content: "Changes published." },
{ type: "danger", title: "Failed", content: "Unable to reach server." },
{ type: "warning", title: "Heads up", content: "Disk almost full." },
{ type: "neutral", title: "FYI", content: "Cache rebuilt." },
{
type: "primary",
title: "New mention",
content: "Jane tagged you in a doc.",
},
{ type: "success", title: "Deployed", content: "Build finished in 12s." },
{ type: "warning", title: "Slow query", content: "Check the indexes." },
{ type: "danger", title: "Payment failed", content: "Card was declined." },
{ type: "neutral", title: "Reminder", content: "Standup in 5 minutes." },
{ type: "primary", title: "Sync complete", content: "Latest data is in." },
];
document
.querySelectorAll<HTMLElement>("[data-toast-history]")
.forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.dataset.toastHistory;
if (action === "push") {
const sample = samples[Math.floor(Math.random() * samples.length)]!;
toast[sample.type]({ title: sample.title, content: sample.content });
} else if (action === "open") {
toggleCenter(true);
} else if (action === "mark-read") {
markAllAsRead();
} else if (action === "clear") {
clearHistory();
}
});
});
</script><Toaster /> Props| prop | type | default | req | description |
|---|---|---|---|---|
| position | "top-left""top-right""top-center""bottom-left""bottom-right""bottom-center" | "bottom-right" | Default viewport corner for spawned toasts. Per-toast overrides are honored. | |
| stacked | boolean | false | Render concurrent toasts as a deck (overlapping cards). Disable for a flat vertical list. | |
| expandOnHover | boolean | true | When stacked is true, fan the deck out into a vertical list on pointer/focus enter. | |
| duration | number | 4000 | Default auto-dismiss duration in milliseconds. Use Infinity on a single toast to make it sticky. | |
| limit | number | 5 | Maximum number of concurrently visible toasts. Older toasts are evicted when the limit is exceeded. | |
| pauseOnHover | boolean | true | Pause auto-dismiss timers (and the progress bar) while the toast is hovered or focus is inside it. | |
| swipeToDismiss | boolean | true | Enable horizontal swipe-to-dismiss via pointer events. Threshold is 40px. | |
| showProgressBar | boolean | true | Render a thin progress bar at the bottom of each toast that animates to zero across the duration. | |
| enableHistory | boolean | false | Track dismissed toasts in the history log. Required for the notification center. | |
| maxHistoryItems | number | 50 | Maximum entries kept in the history log. | |
| historyDrawer | "right""left""top""bottom"false | "right" | Notification center layout. Side-drawer (right/left/top/bottom) or false for a centered modal dialog. | |
| defaultEmphasis | "primary""secondary""tertiary" | "secondary" | Default emphasis for pushed toasts when the input does not specify one. Per-toast overrides via `toast({ emphasis: ... })` are honored. | |
| storageKey | string | undefined | If provided AND enableHistory is true, the history is persisted to localStorage under this key. | |
| onHistoryChange | (history: HistoryEntry[]) => void | undefined | Callback fired whenever the history array changes. Receives the full list. |
toast() APItoast(input) and its convenience methods all return the toast id (a string). Pass that id back to toast.dismiss(id) or use toast.promise() for async tracking.
| prop | type | default | req | description |
|---|---|---|---|---|
| id | string | auto-generated | Stable identifier. If omitted, an id is hashed from title + content. Reusing an id triggers dedupe. | |
| title | string | undefined | Heading text. When omitted the toast renders in a compact row layout (icon + content + close). | |
| content | stringHTMLElement((container: HTMLElement) => void) | undefined | Body text, an HTMLElement, or a render function `(container) => void`. The Svelte block accepts a string only inside Toaster's row template; use toast.custom for arbitrary content. | |
| type | "success""danger""warning""neutral""loading" | "neutral" | Visual feedback. success, danger, warning, neutral, or loading. Loading defaults to sticky duration. | |
| duration | number | Toaster duration | Override the toaster's default duration for this toast. Use Infinity for sticky. | |
| position | ToastPosition | Toaster position | Override the toaster's default position for this toast. | |
| action | { label: string; onClick: (e: Event) => void } | undefined | Render a button inline with the dismiss control. onClick fires when pressed. | |
| containerId | string | undefined | Render this toast inside the element with this id instead of the global viewport (useful for scoped toasters inside dialogs). | |
| sound | booleanstring | false | Play a short audio cue. true plays a built-in WebAudio blip per type; a string is treated as an audio URL. | |
| unread | boolean | true | Initial unread state when pushed to the history log. Defaults to true. | |
| resetDuration | boolean | true | When toast.update() patches an existing toast, whether to restart the timer from zero. Use false for purely visual updates that shouldn't extend lifetime. | |
| onDismiss | () => void | undefined | Callback fired when the toast is removed from the screen (manual dismiss OR auto-close). | |
| onAutoClose | () => void | undefined | Callback fired only when the toast is removed by the auto-dismiss timer. |
toast.success(input) / toast.danger(input) / toast.warning(input) / toast.neutral(input) / toast.loading(input) — shortcuts that set type for you.toast.custom(content, options?) — render arbitrary DOM content (no Alert wrapper).toast.dismiss(id) — force-close a toast.toast.promise(promise, { loading, success, error }, options?) — track an async value through loading → success | error.useToastHistory() composableAvailable from @pindoba/svelte-toast; the Astro runtime exposes the same atoms via getToastHistoryHandles() from @pindoba/astro-toast/runtime.
| prop | type | default | req | description |
|---|---|---|---|---|
| history | HistoryEntry[] | [] | Read-only array of history entries, newest first. | |
| unreadCount | number | 0 | Number of entries with `unread: true`. | |
| isCenterOpen | boolean | false | Whether the built-in notification dialog is currently open. | |
| markAsRead | (id: string) => void | - | Mark a single history entry as read by id. | |
| markAllAsRead | () => void | - | Mark every history entry as read. | |
| clearHistory | () => void | - | Empty the history log. | |
| toggleCenter | (force?: boolean) => void | - | Open or close the built-in notification dialog. Pass true/false to force a state. |