Toast Stack
sonner-style collapsing toast stack — new toasts slide in from the edge, collapse into a pile of cards when not hovered, and expand into the full list while pausing the auto-dismiss countdown when the pointer enters the viewport.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add toast-stack.enToast Stack is a React toast notification component built with Motion (motion/react) and Tailwind CSS. Multiple toasts collapse into a stack, which expands into a full list on hover and pauses the auto-dismiss countdown. Wrap your app in ToastProvider and call toast() from useToast() in any child to send one, with default, success and error variants.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<ToastProvider/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Success or failure feedback after saving a form or changing settings
- Notifying users when background work such as an upload or report export finishes
- Confirming lightweight actions such as copying a link or adding to cart
- Batch jobs in an admin panel, where several results in a row should not flood the screen
- Not a good fit: when a delete needs an "Undo" button, use Undo Snackbar; toasts here only have a title, description and close button
Installation
npx webberui@latest add https://webberui.com/r/toast-stack.en.jsonOr, once registries are configured in components.json, install it as @webberui/toast-stack.
Usage
Wrap ToastProvider once at the outermost level of your app (or around whatever scope needs notifications); any component inside can then send toasts through useToast():
import { ToastProvider, useToast } from "@/components/ui/toast-stack";
function App() {
return (
<ToastProvider position="bottom-right" duration={5000} max={5}>
<SaveButton />
</ToastProvider>
);
}
function SaveButton() {
const { toast } = useToast();
return (
<button
onClick={() =>
toast({
title: "Changes saved",
description: "Settings synced to all devices.",
variant: "success",
})
}
>
Save
</button>
);
}toast() returns the id of that toast; combined with dismiss(id) you can close it early once the flow completes:
const { toast, dismiss } = useToast();
const id = toast({ title: "Uploading…" });
// Close manually once done
dismiss(id);The viewport is built into ToastProvider and rendered into document.body via createPortal, so there is no separate viewport component to mount.
Examples
With the Next.js App Router
ToastProvider has to live in a client component, so create a providers.tsx and put it in the root layout.
// app/providers.tsx
"use client";
import { ToastProvider } from "@/components/ui/toast-stack";
export function Providers({ children }: { children: React.ReactNode }) {
return <ToastProvider position="top-right">{children}</ToastProvider>;
}// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Async save: pending, success, error
Send a pending toast first, close it with dismiss when the request settles, then send a success or error toast based on the result.
"use client";
import { useToast } from "@/components/ui/toast-stack";
export function SaveProfileButton({ data }: { data: FormData }) {
const { toast, dismiss } = useToast();
async function save() {
const pending = toast({ title: "Saving…" });
try {
const res = await fetch("/api/profile", { method: "POST", body: data });
if (!res.ok) throw new Error(String(res.status));
toast({ title: "Profile updated", variant: "success" });
} catch {
toast({
title: "Could not save",
description: "Check your connection and try again.",
variant: "error",
});
} finally {
dismiss(pending);
}
}
return <button onClick={save}>Save</button>;
}Toasts that stay until closed
With duration set to 0, toasts stay until the user closes them or your code calls dismiss. The setting applies to the whole provider.
<ToastProvider duration={0} max={3}>
{children}
</ToastProvider>Props
ToastProvider
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | App content; useToast() must be called within this scope |
position | 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-right' | The corner the viewport docks to |
duration | number | 5000 | Milliseconds before auto-dismiss; set to 0 or below, or Infinity, to disable auto-dismiss |
max | number | 5 | Maximum number of toasts kept at once; beyond that, the oldest card is removed |
useToast()
Returns { toast, dismiss }:
| Method | Signature | Description |
|---|---|---|
toast | (options: ToastOptions) => string | Sends a toast and returns its id |
dismiss | (id: string) => void | Manually closes a toast by id |
ToastOptions
| Field | Type | Default | Description |
|---|---|---|---|
title | string | — | Title |
description | string | — | Description text, shown below the title |
variant | 'default' | 'success' | 'error' | 'default' | Variant, deciding the leading edge color band and the icon (Info / CheckCircle / XCircle) |
How it works
- Collapsed stack: when not hovered, only the frontmost (newest) card stays in the document flow; the rest are absolutely positioned behind it, with
scaledecreasing by distance (-0.05 per layer) and a 14px offset toward the outside of the docked edge so a thin sliver peeks out. From the 4th card onward they are fully hidden (opacity 0 and no pointer events). - Hover to expand: when the pointer enters the viewport (or keyboard focus moves into any toast), the stack expands into the full list — the cards behind switch from absolute positioning back into the document flow, and Motion's layout FLIP springs them to their own positions; on leave, it collapses back into the stack in reverse.
- Enter and exit: new toasts slide in from the docked top or bottom edge (
y: ±120%plus a fade in) while the other cards make room with a layout animation; on exit they slide out toward the left or right edge, andAnimatePresence'spopLayoutmode lets the remaining cards close the gap in sync. - Countdown pausing: while hovered, every
setTimeoutis cleared; on leaving the viewport a full countdown is scheduled again for each toast (reset, not resumed). Whenever a toast is dismissed, pushed out bymax, or the Provider unmounts, its timer is always reclaimed. - Card spacing: when expanded, the spacing is implemented as padding on each card rather than
gapon the container, so moving the pointer between cards never breaks the hover state and the stack does not judder open and closed. - Viewport implementation: the viewport is a
pointer-events-nonecontainer spanning a full-height vertical corridor of the screen (events bubble up from the cards). Its on-screen position is fixed, so the absolute-position measurements taken during apopLayoutexit do not jump as the container height changes.
Accessibility
- The viewport carries
aria-live="polite"and each toast carriesrole="status", so new toasts are announced by the screen reader without interrupting what the user is doing - Every toast has a close button with an
aria-label; keyboard focus entering the viewport behaves like hover — the stack expands and the countdown pauses, resuming once focus leaves - When the user has "reduce motion" enabled at the system level, the slide in / slide out and the stack offsets all drop to zero and degrade to a brief fade in / fade out with no sense of movement
- Toasts never steal focus; after sending one, focus stays on whatever element the user was working with
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client"; see the example above for putting ToastProvider in a layout. The toast viewport is portaled to document.body only after mounting, so the server renders nothing for it.
Can one toast have a different duration?
No. duration is a ToastProvider setting and applies to every toast. For per-toast control, set duration to 0 and call dismiss(id) yourself with a setTimeout.
Can I call toast() outside a React component?
No. toast comes from useToast(), which must be called in a component inside ToastProvider; otherwise it throws.
What happens when many toasts arrive at once?
Beyond max (default 5), the oldest one is removed. The collapsed stack shows at most 3 cards; the rest are hidden until the stack expands on hover.
Do screen readers announce toasts?
Yes. The viewport has aria-live="polite" and each toast has role="status", so announcements do not interrupt the user and focus is never stolen. With the OS "reduce motion" setting on, slides and stack offsets become simple fades.
Dynamic Island Toast
An iOS Dynamic Island-style notification — an idle pill expands fluidly into a notification card, multiple notifications stack behind it with their edges peeking out, and a loading state can update in place to success.
Cursor Tooltip
An elastic tooltip that follows the cursor, tracking its coordinates with a spring and flipping sides automatically near the edges, with keyboard focus anchoring.