Toast stack
Free · MITSelf-contained toaster with toast() API: cards stack and expand on hover, swipe away with a drag, morph from loading to success or error for promises, and pause their timers on hover.
Installation
$ npx shadcn@latest add https://goodui.dev/r/toast-stack.jsonThe file lands in components/goodui/toast-stack.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/toast-stack works too.
Usage
// app/layout.tsx: mount the toaster once
import { Toaster } from "@/components/goodui/toast-stack"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster position="bottom-right" />
</body>
</html>
)
}
// anywhere in a client component
import { toast } from "@/components/goodui/toast-stack"
function cancelAppointment(client: string, time: string) {
toast("Appointment cancelled", {
description: `${client} on ${time}`,
duration: 6000,
action: { label: "Undo", onClick: () => toast.success("Appointment restored") },
})
}When to use
- Short confirmations after an action: "Booking confirmed", "Invoice sent".
- Async work that should show loading, then success or error, in the same toast (toast.promise).
- Reversible actions with an Undo button ("Appointment cancelled" + Undo).
- Background events the user did not trigger directly, such as going offline.
- Errors the user must fix before going on (form validation): show them inline with input-shake or an alert.
- Persistent page-level state such as a trial ending: use an alert banner.
- A running list of activity the user can scroll back through: use animated-list.
| If you need | Use |
|---|---|
| Plain message | toast("Saved") |
| Outcome with an icon | toast.success / .error / .warning / .info |
| Async request | toast.promise(fn, { loading, success, error }) |
| Update an existing toast | toast(title, { id }) with the same id |
| Must stay until closed | duration: Infinity (loading toasts never close on their own) |
| Always show the full list | <Toaster expand /> |
Examples
Loading, then success or error
toast.promise(() => sendInvoice(1042), {
loading: "Sending invoice #1042…",
description: "Emailing a PDF to the client",
success: (id) => `Invoice #${id} sent`,
error: () => "Invoice #1042 could not be sent. Try again.",
action: { label: "View", onClick: () => router.push("/invoices/1042") },
})Manual loading toast updated by id
const id = toast.loading("Syncing Google Calendar…")
await sync()
toast.success("Calendar synced", { id })Translated toaster
<Toaster position="top-center" labels={{ notifications: "Benachrichtigungen (Alt+T)", close: "Schließen" }} />API reference
| Prop | Type | Default |
|---|---|---|
| position Screen corner or edge the stack is pinned to; top positions stack downward, bottom ones upward. | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" | "bottom-right" |
| visibleToasts Toasts visible in the collapsed stack. | number | 3 |
| duration Default auto-dismiss time in ms. | number | 4000 |
| gap Space between toasts when expanded, in px. | number | 12 |
| expand Keep the stack expanded instead of expanding on hover. | boolean | false |
| showTimer Thin bar showing the time left. | boolean | true |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<ToasterLabels> | — |
| sound Plays a sound for each new toast by type (success, error, warning, otherwise "notify") when the visitor has sounds on (lib/goodui-sounds); a sound name is used for every type. Off by default. | boolean | SoundName | — |
Also accepts every <section> attribute.
Accessibility
| Alt+T | Moves focus to the newest toast (listened for on window while the Toaster is mounted). |
| Tab | Moves between toasts and their action and close buttons; focus inside the stack expands it and pauses timers. |
| Escape | Closes the focused toast, unless dismissible is false. |
- The toaster is a <section> with aria-live="polite" and aria-relevant="additions text", labelled "Notifications (Alt+T)", so new toasts and text changes (a promise resolving) are announced.
- Each toast is a focusable list item; the close button has an aria-label from labels.close.
- Timers pause while the pointer is over the stack, while focus is inside it and while the tab is hidden, so nothing disappears while someone is reading it.
- Built-in strings (notifications, close) go through the labels prop for translation.
Motion
- New toasts enter 24px from the edge at 0.96 scale; position, scale and height move on a spring (stiffness 380, damping 34, mass 0.9). Exit is a 180 ms fade to 0.94 scale.
- Collapsed, older toasts scale down 5% per level and peek out 10px each; hover or focus fans them out to their real heights.
- Dragging sideways fades the card; past 80px or a 600 px/s flick it flies off in 200 ms and closes. Otherwise it snaps back.
- When the type changes (loading to success), the icon pops in on a spring from 0.4 scale and -45° rotation. A thin bar at the bottom shrinks over the duration.
- Reduced motion: toasts fade without moving, the timer bar is hidden, the icon swap is a fade and the loading spinner slows to 2.4 s per turn.
- The store is a tiny module-level list read with useSyncExternalStore; only the Toaster re-renders when toasts change. Timers are one setTimeout per visible toast, with the remaining time kept across pauses.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Mount <Toaster /> exactly once (root layout); call toast() from anywhere on the client. The store is global, so a second Toaster would show the same toasts twice.
- toast() returns the id; toast.promise() returns the promise, not an id. Pass options.id to toast.promise if you need to dismiss it.
- Pass the promise as a function (() => save()) or a promise; success and error can be functions of the result or error.
- Only one action per toast; clicking it runs onClick and closes the toast. There is no cancel button option.
- Do not use toasts for validation errors or anything that needs a decision; they auto-close after 4 s by default.
FAQ
Is it compatible with Sonner?
The call shapes are similar (toast(), toast.success, toast.promise, toast.dismiss) but it is a separate, self-contained store. Replace the import and keep one Toaster.
What happens if the user closes a loading toast before the promise settles?
It stays closed: the success or error update only applies if the toast is still in the list.
How do I keep a toast open?
Pass duration: Infinity, or use type loading. Updating a toast by id restarts its timer.
Related
Soft, solid, outline, left-accent, glass, neo and full-width banner alerts with tone-picked icons, actions, slide, fade, shake or pop entrances, smooth collapse on dismiss and a pausable auto-dismiss countdown.
Notification stack where new items spring in from the top, older ones glide down, slightly dimmed in the depth variant, and items beyond the limit blur away.
Count badge for icon buttons that springs in, bumps when the count rises, rolls its digits, caps at 99+ and offers a pulse ring or dot variant.
Check or X in a circle that draws its stroke, fills, then pops with a ray-and-ring burst, for payment and booking result screens.