Toast
Stacked notifications that fan out on hover, swipe to dismiss, and track promises.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/toast.jsoncomponents/examples/toast-demo.tsx
"use client";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/toast";
export default function ToastDemo() {
return (
<div className="flex flex-wrap justify-center gap-2">
<Button variant="secondary" onClick={() => toast("Draft saved", { description: "We'll keep it for 30 days." })}>Default</Button>
<Button variant="secondary" onClick={() => toast.success("Invite sent to grace@acme.com")}>Success</Button>
<Button variant="secondary" onClick={() => toast.error("Couldn't reach the server", { action: { label: "Retry", onClick: () => toast.success("Connected") } })}>Error + action</Button>
<Button
onClick={() =>
toast.promise(new Promise((r) => setTimeout(r, 1600)), { loading: "Deploying to production…", success: "Deployed in 1.6s", error: "Deploy failed" })
}
>
Promise
</Button>
</div>
);
}When to use
- Brief confirmation after an action: saved, sent, copied.
- Async results with toast.promise.
When not to use
- Errors that block progress: show them inline.
- Anything people must act on later: use a notification center.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/toast.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/toast.tsx"use client"; import * as React from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { AlertCircle, CheckCircle2, Info, Loader2, X } from "lucide-react"; import { cn } from "@/lib/utils"; type Kind = "default" | "success" | "error" | "loading"; interface ToastData { id: number; title: string; description?: string; kind: Kind; duration: number; action?: { label: string; onClick: () => void }; } type Options = Partial<Pick<ToastData, "description" | "duration" | "action">>; // A tiny module-level store so `toast()` works anywhere, like sonner. let items: ToastData[] = []; let nextId = 1; const listeners = new Set<() => void>(); const emit = () => listeners.forEach((l) => l()); function push(title: string, kind: Kind, opts: Options = {}) { const id = nextId++; items = [{ id, title, kind, duration: kind === "loading" ? Infinity : 4000, ...opts }, ...items].slice(0, 6); emit(); return id; } function update(id: number, patch: Partial<ToastData>) { items = items.map((t) => (t.id === id ? { ...t, ...patch } : t)); emit(); } export function dismiss(id: number) { items = items.filter((t) => t.id !== id); emit(); } export const toast = Object.assign((title: string, opts?: Options) => push(title, "default", opts), { success: (title: string, opts?: Options) => push(title, "success", opts), error: (title: string, opts?: Options) => push(title, "error", opts), /** Shows a loading toast that turns into success or error when the promise settles. */ promise: <T,>(p: Promise<T>, msgs: { loading: string; success: string | ((v: T) => string); error: string }) => { const id = push(msgs.loading, "loading"); p.then( (v) => update(id, { kind: "success", title: typeof msgs.success === "function" ? msgs.success(v) : msgs.success, duration: 4000 }), () => update(id, { kind: "error", title: msgs.error, duration: 5000 }), ); return p; }, }); const subscribe = (l: () => void) => { listeners.add(l); return () => listeners.delete(l); }; const noop = () => () => {}; const icons: Record<Kind, React.ReactNode> = { default: <Info className="text-muted-foreground" />, success: <CheckCircle2 className="text-emerald-500" />, error: <AlertCircle className="text-destructive" />, loading: <Loader2 className="animate-spin text-muted-foreground" />, }; /** Render once near the root. Toasts stack; hover to fan them out and pause timers. */ export function Toaster({ position = "bottom-right" }: { position?: "bottom-right" | "bottom-center" | "top-center" }) { const list = React.useSyncExternalStore(subscribe, () => items, () => items); const mounted = React.useSyncExternalStore(noop, () => true, () => false); const [expanded, setExpanded] = React.useState(false); const top = position === "top-center"; if (!mounted) return null; return createPortal( <section aria-label="Notifications" onPointerEnter={() => setExpanded(true)} onPointerLeave={() => setExpanded(false)} className={cn( "fixed z-[200] w-[min(22rem,calc(100vw-2rem))]", position === "bottom-right" && "bottom-4 right-4", position === "bottom-center" && "bottom-4 left-1/2 -translate-x-1/2", top && "left-1/2 top-4 -translate-x-1/2", )} > <ol className="relative"> <AnimatePresence initial={false}> {list.map((t, i) => ( <ToastItem key={t.id} toast={t} index={i} expanded={expanded} top={top} /> ))} </AnimatePresence> </ol> </section>, document.body, ); } function ToastItem({ toast: t, index, expanded, top }: { toast: ToastData; index: number; expanded: boolean; top: boolean }) { const remaining = React.useRef(t.duration); const started = React.useRef(0); React.useEffect(() => { if (!Number.isFinite(t.duration)) return; remaining.current = t.duration; }, [t.duration, t.kind]); React.useEffect(() => { if (!Number.isFinite(remaining.current) || expanded) return; started.current = Date.now(); const timer = setTimeout(() => dismiss(t.id), remaining.current); return () => { clearTimeout(timer); remaining.current -= Date.now() - started.current; }; }, [expanded, t.id, t.duration, t.kind]); const dir = top ? 1 : -1; const collapsedY = dir * index * 10; const expandedY = dir * index * 76; return ( <motion.li layout role="status" aria-live="polite" initial={{ opacity: 0, y: dir * -24, scale: 0.96 }} animate={{ opacity: index > 2 && !expanded ? 0 : 1, y: expanded ? expandedY : collapsedY, scale: expanded ? 1 : 1 - index * 0.05, }} exit={{ opacity: 0, scale: 0.94, transition: { duration: 0.15 } }} transition={{ type: "spring", stiffness: 420, damping: 34 }} drag="x" dragConstraints={{ left: 0, right: 0 }} dragElastic={0.6} onDragEnd={(_, info) => Math.abs(info.offset.x) > 80 && dismiss(t.id)} style={{ zIndex: 100 - index, transformOrigin: top ? "top center" : "bottom center" }} className={cn( "absolute inset-x-0 flex items-start gap-3 rounded-lg border border-border bg-popover p-3.5 pr-9 text-sm text-popover-foreground shadow-xl [&_svg]:size-[18px]", top ? "top-0" : "bottom-0", )} > <span className="mt-px shrink-0">{icons[t.kind]}</span> <div className="min-w-0 flex-1"> <p className="font-medium">{t.title}</p> {t.description && <p className="mt-0.5 text-muted-foreground">{t.description}</p>} {t.action && ( <button type="button" onClick={() => { t.action!.onClick(); dismiss(t.id); }} className="mt-2 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground" > {t.action.label} </button> )} </div> <button type="button" aria-label="Dismiss" onClick={() => dismiss(t.id)} className="absolute right-2 top-2 grid size-6 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground [&_svg]:size-3.5"> <X /> </button> </motion.li> ); }
Usage
Import it from @/components/ui/toast after installing.
example.tsx
// app/layout.tsx: render once
import { Toaster } from "@/components/ui/toast";
// <body>{children}<Toaster /></body>
// anywhere
import { toast } from "@/components/ui/toast";
toast.success("Invite sent");
toast.promise(save(), { loading: "Saving…", success: "Saved", error: "Couldn't save" });API reference
Toaster
Renders the stack. Place once near the root.
| Prop | Type | Default | Description |
|---|---|---|---|
| position | "bottom-right" | "bottom-center" | "top-center" | "bottom-right" | Where toasts appear. |
toast
Shows a toast from anywhere. Returns its id.
| Prop | Type | Default | Description |
|---|---|---|---|
| toast(title, opts?) | function | – | Neutral toast. Also toast.success, toast.error and toast.promise. |
| opts | { description?, duration?, action?: { label, onClick } } | – | Body text, timeout (ms) and an action button. |
dismiss
Removes a toast by id.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | number | – | Returned by toast(). |
Keyboard interactions
| Keys | Action |
|---|---|
| Tab | Reach the action and close buttons |
Accessibility
- Each toast is role="status" with aria-live="polite".
- Timers pause while hovered so people can read and act.
Motion
- Toasts stack with scale offsets and fan out on hover.
- Drag sideways more than 80px to dismiss.
Responsive behavior
- Width is min(352px, 100vw − 32px).
Performance
- A tiny module store; at most six toasts kept.
Notes for AI
- Render <Toaster /> once in the root layout, not per page.
This page as Markdown, for assistants and coding agents:
/components/toast/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt