A token-based notification stack with dismissal, optional actions, and pause-on-interaction timing.
Changes saved
Your team can see the latest version.
Invitation sent
Alex can join the workspace from their email.
import { ToastStack } from "@/components/ballmac/toast-stack"
export default function ToastStackDemo() {
return (
<ToastStack
className="w-full max-w-sm"
durationMs={0}
defaultToasts={[
{
id: "saved",
title: "Changes saved",
description: "Your team can see the latest version.",
tone: "success",
},
{
id: "invite",
title: "Invitation sent",
description: "Alex can join the workspace from their email.",
tone: "info",
},
]}
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/toast-stackInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/toast-stack.tsx// Ballmac UI: Toast Stack. https://ui.ballmac.com/components/toast-stack "use client" import * as React from "react" import { CircleCheck, CircleAlert, Info, X } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type ToastMessage = { /** Stable ID used for dismissal and updates. */ id: string /** Short notification headline. */ title: string /** Optional detail below the headline. */ description?: string /** Visual and semantic tone. */ tone?: "info" | "success" | "error" /** Optional action label. */ actionLabel?: string /** Called when the action is selected. */ onAction?: () => void } type ToastStackProps = Omit<React.ComponentProps<"section">, "children"> & { /** Controlled toast list. */ toasts?: ToastMessage[] /** Initial toast list when uncontrolled. */ defaultToasts?: ToastMessage[] /** Called when a toast is dismissed. */ onToastsChange?: (toasts: ToastMessage[]) => void /** Auto-dismiss delay; set to 0 to keep toasts until dismissed. */ durationMs?: number /** Accessible label for the stack. */ label?: string } function ToastStack({ className, toasts, defaultToasts = [], onToastsChange, durationMs = 5000, label, ...props }: ToastStackProps) { const msg = useMessages() label ??= msg("toast-stack.label", "Notifications") const [internal, setInternal] = React.useState(defaultToasts) const [paused, setPaused] = React.useState(false) const current = toasts ?? internal const dismiss = React.useCallback( (id: string) => { const next = current.filter((toast) => toast.id !== id) if (toasts === undefined) setInternal(next) onToastsChange?.(next) }, [current, toasts, onToastsChange], ) React.useEffect(() => { if (durationMs <= 0 || paused || !current.length) return const timer = window.setTimeout(() => dismiss(current[0].id), durationMs) return () => window.clearTimeout(timer) }, [current, durationMs, paused, dismiss]) return ( <section data-slot="toast-stack" aria-label={label} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false) }} className={cn("w-full max-w-sm", className)} {...props} > <ol className="flex flex-col gap-2"> {current.map((toast) => { const tone = toast.tone ?? "info" const Icon = tone === "success" ? CircleCheck : tone === "error" ? CircleAlert : Info return ( <li key={toast.id} data-slot="toast-stack-item" onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault() dismiss(toast.id) } }} className="bg-background/90 flex min-w-0 items-start gap-3 rounded-xl border border-border p-3 shadow-lg backdrop-blur-xl transition-[transform,opacity] duration-200 motion-reduce:transition-none" > <span aria-hidden="true" className={cn( "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg", tone === "info" && "bg-primary/10 text-primary", tone === "success" && "bg-chart-2/10 text-chart-2", tone === "error" && "bg-destructive/10 text-destructive", )} > <Icon className="size-4" /> </span> <div className="min-w-0 flex-1"> <p className="text-sm font-semibold">{toast.title}</p> {toast.description && ( <p className="text-muted-foreground mt-0.5 text-xs leading-relaxed"> {toast.description} </p> )} {toast.actionLabel && toast.onAction && ( <button type="button" onClick={toast.onAction} className="text-primary hover:underline mt-2 rounded-sm text-xs font-semibold outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {toast.actionLabel} </button> )} </div> <button type="button" aria-label={msg("toast-stack.dismiss", "Dismiss {title}", { title: toast.title })} onClick={() => dismiss(toast.id)} className="text-muted-foreground hover:bg-accent hover:text-foreground flex size-8 shrink-0 items-center justify-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <X aria-hidden="true" className="size-4" /> </button> </li> ) })} </ol> <span className="sr-only" aria-live="polite"> {current.at(-1)?.title ?? ""} </span> </section> ) } export { ToastStack, type ToastStackProps, type ToastMessage }Update the import paths to match your project setup.
Usage
import { ToastStack } from "@/components/ballmac/toast-stack"The full example is in the Code tab above.
Examples
States and variants
Upload paused
Check your connection and try again.
import { ToastStack } from "@/components/ballmac/toast-stack"
export default function ToastStackStates() {
return (
<ToastStack
className="w-full max-w-sm"
durationMs={0}
defaultToasts={[
{
id: "retry",
title: "Upload paused",
description: "Check your connection and try again.",
tone: "error",
},
]}
/>
)
}API reference
| Prop | Type | Default |
|---|---|---|
toastsControlled toast list. | ToastMessage[] | — |
defaultToastsInitial toast list when uncontrolled. | ToastMessage[] | [] |
onToastsChangeCalled when a toast is dismissed. | (toasts: ToastMessage[]) => void | — |
durationMsAuto-dismiss delay; set to 0 to keep toasts until dismissed. | number | 5000 |
labelAccessible label for the stack. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab / Enter / Escape | Activate actions or dismiss a focused toast |
Use with AI
A token-based notification stack with dismissal, optional actions, and pause-on-interaction timing. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Toast Stack (@ballmac/toast-stack) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Confirm an asynchronous action
- Offer undo or retry after a task
Not for
- Use banner for persistent page-wide messages
Registry JSON: https://ui.ballmac.com/r/toast-stack.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Notification Stack
macOS Notification Center cards: collapsed, the newest sits in front with two peeking behind; click or press Enter to spring the stack open. New cards slide in from the top; each can be dismissed.
Alert Dialog
Focus-managed confirmation for consequential actions, with a clear cancel path, optional media, and a token-based destructive action.
Alert
A semantic callout with five theme-aware tones, clear icon placement, an action row, and opt-in urgent announcements.