Toast
Brief notices stacked in a corner, raised from anywhere through one manager.
Base UI's toast manager as shadcn wires it, not sonner. Vibra draws each toast as a floating sheet — a hairline ring, --shadow-modal and the kit's focus outline — where shadcn uses a border and shadow-lg, and times its entry, stacking and exit on the motion tokens rather than a fixed 500ms. A loading toast's spinner holds still for a reader who has asked for less motion, by the OS setting or a [data-motion="reduced"] above. Mount Toaster once in the root layout; toast.add then works from any client code, and Notify is the one-call-per-intent API over the same manager. The stack is a polite live region that never takes the focus: F6 moves into it, Tab walks its toasts and Esc dismisses the one in focus. priority: "high" reads a toast out at once through role="alert", for a failure the reader must know about now. Toaster's position puts the stack in any corner or centred on either edge, where shadcn's stays bottom right — a top stack grows down and its toasts come in from above — and label names a second stack's region apart from the first.
Install
npx shadcn@latest add @vibra/toastNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
// <Toaster /> is mounted once in the root layout; `toast.add` reaches it from anywhere on the client.
export default function ToastDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
onClick={() =>
toast.add({
title: "Report scheduled",
description: "Weekly revenue goes to 4 people every Monday at 08:00.",
type: "success",
})
}
>
Schedule report
</Button>
<Button
variant="outline"
onClick={() =>
toast.add({
title: "Export failed",
description: "The revenue export timed out after 30 seconds.",
type: "error",
actionProps: {
children: "Retry",
onClick: () => toast.add({ title: "Export queued again", type: "info" }),
},
})
}
>
Export revenue
</Button>
</div>
)
}Success
A title and nothing more, beside the invoice it is about. It leaves on its own.
"use client"
import { SendIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
// A title and nothing more: the row already says which invoice, so the toast
// only has to confirm that the reminder went. It leaves on its own.
export default function ToastSuccess() {
return (
<div className="flex w-full max-w-sm items-center justify-between gap-4 rounded-lg bg-card p-3 ring-1 ring-border">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">Fathom Media</span>
<span className="text-xs text-muted-foreground">
<span className="font-mono">INV-100018</span> · <span className="tabular-nums">$776.85</span> · due 14 Sep
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => toast.add({ title: "Reminder sent to Maja Nyman", type: "success" })}
>
<SendIcon data-icon="inline-start" aria-hidden="true" />
Send reminder
</Button>
</div>
)
}Error, announced at once
A declined card as a high-priority toast: read out at once through role="alert", and still taking no focus.
"use client"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
// A failure the reader has to know about now: `priority: "high"` has the
// stack read it out at once through role="alert", where an ordinary toast
// waits its turn in the polite region. It still takes no focus.
export default function ToastError() {
return (
<div className="flex w-full max-w-sm items-center justify-between gap-4 rounded-lg bg-card p-3 ring-1 ring-border">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">Alder Health</span>
<span className="text-xs text-muted-foreground">
<span className="font-mono">INV-100010</span> · <span className="tabular-nums">$1,200.00</span> · overdue
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
toast.add({
title: "Card declined",
description: (
<>
Nothing was charged, and <span className="font-mono">INV-100010</span> stays overdue.
</>
),
type: "error",
priority: "high",
})
}
>
Retry the charge
</Button>
</div>
)
}With Undo
Archive at once and offer the way back. Undo restores the order in place, and the focus moves on when a row leaves.
"use client"
import * as React from "react"
import { ArchiveIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
const ORDERS = [
{ number: "ORD-100014", customer: "Harborview Robotics", total: "$422.98" },
{ number: "ORD-100095", customer: "Wavelength Health", total: "$183.99" },
{ number: "ORD-100013", customer: "Foxglove Health", total: "$1,285.00" },
]
// Archiving happens at once and the toast offers the way back, which beats
// asking first. Undo puts the order back where it was and closes the toast.
// The pressed button leaves with its row, so the focus moves to the next one.
export default function ToastUndo() {
const [archived, setArchived] = React.useState<string[]>([])
const [restored, setRestored] = React.useState("")
const list = React.useRef<HTMLUListElement>(null)
const focusNext = React.useRef<string | null>(null)
const shown = ORDERS.filter((order) => !archived.includes(order.number))
React.useEffect(() => {
if (focusNext.current === null) return
const target = focusNext.current
focusNext.current = null
const button = target ? list.current?.querySelector<HTMLElement>(`[data-order="${target}"]`) : null
;(button ?? list.current)?.focus()
}, [archived])
function archive(number: string) {
const at = shown.findIndex((order) => order.number === number)
focusNext.current = (shown[at + 1] ?? shown[at - 1])?.number ?? ""
setArchived((current) => [...current, number])
setRestored("")
const id = toast.add({
title: (
<>
<span className="font-mono">{number}</span> archived
</>
),
actionProps: {
children: "Undo",
onClick: () => {
setArchived((current) => current.filter((entry) => entry !== number))
setRestored(`${number} is back in Fulfilled.`)
toast.close(id)
},
},
})
}
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<h3 id="toast-undo-title" className="text-sm font-semibold">
Fulfilled · <span className="tabular-nums">{shown.length}</span>
</h3>
<ul
ref={list}
tabIndex={-1}
aria-labelledby="toast-undo-title"
className="flex min-h-10 flex-col divide-y divide-border rounded-lg ring-1 ring-border outline-none"
>
{shown.map((order) => (
<li key={order.number} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<span className="flex min-w-0 flex-col">
<span className="truncate">{order.customer}</span>
<span className="font-mono text-xs text-muted-foreground">{order.number}</span>
</span>
<span className="ms-auto tabular-nums">{order.total}</span>
<Button
variant="ghost"
size="icon-sm"
data-order={order.number}
aria-label={`Archive ${order.number}`}
onClick={() => archive(order.number)}
>
<ArchiveIcon aria-hidden="true" />
</Button>
</li>
))}
</ul>
<p role="status" className="text-xs text-muted-foreground">
{shown.length === 0 ? "Nothing left to archive." : restored}
</p>
</div>
)
}With a description
A short title and a description that carries the figures and what the next step will need.
"use client"
import { UserPlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
// The title says what happened in a few words; the description carries what
// the reader needs next — the figures, and what the next step will cost.
export default function ToastDescription() {
return (
<Button
variant="outline"
onClick={() =>
toast.add({
title: "Invites sent to 2 people",
description: "That fills Northwind’s 30 seats on Team. The next invite needs the Enterprise plan.",
type: "warning",
})
}
>
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
Invite 2 people
</Button>
)
}Loading, then done
One toast from start to finish: it spins while the export runs, holding still under reduced motion, and turns into the result in place.
"use client"
import { DownloadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
/** Stands in for the export request: the file is ready in under two seconds. */
function exportOrders() {
return new Promise<{ file: string; rows: number }>((resolve) =>
setTimeout(() => resolve({ file: "orders-2026-08.csv", rows: 77 }), 1800)
)
}
// One toast from start to finish: it spins while the promise runs — holding
// still for a reader who asked for less motion — and turns into the result in
// place, so the polite region reads the change instead of a second toast.
export default function ToastPromise() {
return (
<Button
variant="outline"
onClick={() =>
toast
.promise(exportOrders(), {
loading: { title: "Exporting 77 August orders…" },
success: (result) => ({
title: "Export ready",
description: (
<>
<span className="font-mono">{result.file}</span> · {result.rows} rows
</>
),
}),
error: { title: "The export failed", description: "Nothing was saved. Try again in a minute." },
})
// The toast has told the reader; the rejection is handled here.
.catch(() => {})
}
>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export August orders
</Button>
)
}Stays until dismissed
A notice that matters until tonight, kept up with timeout 0. F6 reaches the stack and Esc dismisses it.
"use client"
import { WrenchIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { toast } from "@/components/ui/toast"
// `timeout: 0` keeps it up until someone closes it, for a notice that matters
// until tonight. The id means a second press updates it instead of stacking a
// copy. It never takes the focus: F6 reaches the stack and Esc dismisses it.
export default function ToastPersistent() {
return (
<div className="flex flex-col items-center gap-3">
<Button
variant="outline"
onClick={() =>
toast.add({
id: "checkout-maintenance",
title: "Checkout pauses tonight",
description: "From 23:00 to 23:30 UTC, while the payments database is upgraded.",
type: "info",
timeout: 0,
})
}
>
<WrenchIcon data-icon="inline-start" aria-hidden="true" />
Post maintenance notice
</Button>
<p className="flex flex-wrap items-center justify-center gap-1.5 text-xs text-muted-foreground">
<KbdShortcut keys="f6" size="sm" /> reaches the notifications,
<KbdShortcut keys="esc" size="sm" /> dismisses one.
</p>
</div>
)
}Updates in place
Each press reuses one id, so the toast already up changes its count instead of a new one stacking per order.
"use client"
import * as React from "react"
import { ListPlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
const ORDERS = ["ORD-100037", "ORD-100013", "ORD-100073"]
// Every press reuses one id, so the toast already up changes in place — its
// count, its list and a fresh timer — instead of a new one piling onto the
// stack for each order. When it goes, the export starts over.
export default function ToastUpdate() {
const queued = React.useRef<string[]>([])
function add(order: string) {
if (!queued.current.includes(order)) queued.current = [...queued.current, order]
const count = queued.current.length
toast.add({
id: "export-queue",
title: `${count} ${count === 1 ? "order" : "orders"} in the export`,
description: <span className="font-mono text-xs">{queued.current.join(", ")}</span>,
onClose: () => {
queued.current = []
},
})
}
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{ORDERS.map((order) => (
<Button key={order} variant="outline" size="sm" aria-label={`Add ${order} to the export`} onClick={() => add(order)}>
<ListPlusIcon data-icon="inline-start" aria-hidden="true" />
<span className="font-mono text-xs">{order}</span>
</Button>
))}
</div>
)
}Stacked
Three results of one import stack newest first. Pointing at them or tabbing in fans them out and holds their timers.
"use client"
import { UploadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
const RESULTS = [
{ title: "46 customers imported", type: "success" },
{ title: "3 merged with existing customers", description: "Matched on email; the newer details win.", type: "info" },
{ title: "2 rows skipped", description: "Rows 17 and 31 have no email address.", type: "warning" },
]
// Three results of one import arrive together and stack, newest in front.
// Pointing at the stack or tabbing into it fans them out and holds their
// timers, so none leaves while it is being read.
export default function ToastStacked() {
return (
<Button
variant="outline"
onClick={() => RESULTS.forEach((result, index) => setTimeout(() => toast.add(result), index * 250))}
>
<UploadIcon data-icon="inline-start" aria-hidden="true" />
Import customers.csv
</Button>
)
}Positioned
A second stack at the top centre, with a region name of its own. It grows down, and its toasts come in from above.
"use client"
import { Share2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Toaster, createToastManager } from "@/components/ui/toast"
// A stack of its own, for a layout whose bottom corner is taken. At the top it
// grows down, its toasts come in from above and swipe up to go, and its
// region has a name of its own beside the page's main Notifications.
const sharing = createToastManager()
export default function ToastPositioned() {
return (
<>
<Toaster toastManager={sharing} position="top-center" label="Sharing notifications" />
<Button
variant="outline"
onClick={() =>
sharing.add({
title: "Shared with Finance",
description: "4 people can now open Revenue by channel.",
type: "success",
})
}
>
<Share2Icon data-icon="inline-start" aria-hidden="true" />
Share with Finance
</Button>
</>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| toast.add | (options: { id?: string; title?: React.ReactNode; description?: React.ReactNode; type?: string; timeout?: number; priority?: "low" | "high"; actionProps?: React.ComponentProps<"button">; onClose?: () => void }) => string | — | Raises a toast and returns its id; type "success", "info", "warning", "error" or "loading" picks the icon. An id already up updates that toast in place, timeout: 0 keeps it until it is dismissed, and priority: "high" announces it at once. |
| toast.promise | <T>(promise: Promise<T>, options: { loading; success; error }) => Promise<T> | — | Shows a loading toast and turns it into the success or error toast when the promise settles; each option is toast.add's options, or a function of the result. |
| toast.close | (id: string) => void | — | Closes one toast by id. |
| Toaster.toastManager | ToastManager | toast | The manager the stack listens to; one from createToastManager keeps a second stack apart. |
| Toaster.position | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" | "bottom-right" | Where the stack sits from sm up; on a phone it spans the width on its edge. A top stack grows down. |
| Toaster.label | string | "Notifications" | The name of the stack's region. A second stack on the page needs one of its own. |
Dependencies
Source
"use client"
import * as React from "react"
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { XIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const toast = ToastPrimitive.createToastManager()
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
return <ToastPrimitive.Provider {...props} />
}
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
}
type ToastPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"
function sidesOf(position: ToastPosition) {
const [side, align] = position.split("-") as ["top" | "bottom", "left" | "center" | "right"]
return { side, align }
}
// Where the stack sits. The side also decides which way it grows and where a
// toast comes in from, and the viewport hands that to its toasts as custom
// properties — --toast-dir is -1 for a stack growing up from the bottom edge
// and 1 for one hanging from the top — so each toast needs no variant of its
// own. On a phone every stack spans the width; from sm it keeps to its corner.
const toastViewportVariants = cva(
"pointer-events-none fixed inset-x-4 z-50 mx-auto w-auto max-w-sm outline-none sm:w-full",
{
variants: {
side: {
bottom: "bottom-4 [--toast-bottom:0px] [--toast-dir:-1] [--toast-origin:bottom] [--toast-top:auto]",
top: "top-4 [--toast-bottom:auto] [--toast-dir:1] [--toast-origin:top] [--toast-top:0px]",
},
align: {
left: "sm:right-auto sm:left-4 sm:mx-0",
center: "",
right: "sm:right-4 sm:left-auto sm:mx-0",
},
},
defaultVariants: { side: "bottom", align: "right" },
}
)
function ToastViewport({
className,
position = "bottom-right",
...props
}: ToastPrimitive.Viewport.Props & { position?: ToastPosition }) {
return (
<ToastPrimitive.Viewport
data-slot="toast-viewport"
data-position={position}
className={cn(toastViewportVariants(sidesOf(position)), className)}
{...props}
/>
)
}
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
return (
<ToastPrimitive.Root
data-slot="toast"
className={cn(
// Anchored on the edge the viewport names, and moved along the way its
// stack grows (--toast-dir); outside a kit viewport, the bottom edge.
"group/toast pointer-events-auto absolute right-0 [top:var(--toast-top,auto)] [bottom:var(--toast-bottom,0px)] z-[calc(1000-var(--toast-index))] w-full [transform-origin:var(--toast-origin,bottom)] rounded-2xl bg-popover text-popover-foreground ring-1 ring-border shadow-(--shadow-modal) will-change-transform outline-none select-none focus-ring",
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-dir,-1)*(var(--toast-offset-y)+var(--toast-index)*var(--gap))+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+var(--toast-dir,-1)*(var(--toast-index)*var(--peek)+var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_var(--duration-slow)_var(--ease-emphasized),opacity_var(--duration-slow),height_var(--duration-fast)]",
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
"data-limited:opacity-0 data-starting-style:[transform:translateY(calc(var(--toast-dir,-1)*-150%))]",
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(calc(var(--toast-dir,-1)*-150%))]",
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
className
)}
{...props}
/>
)
}
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
return (
<ToastPrimitive.Content
data-slot="toast-content"
className={cn(
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-(--duration-base) ease-(--ease-emphasized) data-behind:opacity-0 data-expanded:opacity-100",
className
)}
{...props}
/>
)
}
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
return (
<ToastPrimitive.Title
data-slot="toast-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
)
}
function ToastDescription({
className,
...props
}: ToastPrimitive.Description.Props) {
return (
<ToastPrimitive.Description
data-slot="toast-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function ToastAction({
className,
render = <Button variant="outline" size="sm" />,
...props
}: ToastPrimitive.Action.Props) {
return (
<ToastPrimitive.Action
data-slot="toast-action"
render={render}
className={cn("shrink-0", className)}
{...props}
/>
)
}
function ToastClose({
className,
children,
render = <Button variant="ghost" size="icon-sm" />,
...props
}: ToastPrimitive.Close.Props) {
return (
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close toast"
render={render}
className={cn(
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<XIcon aria-hidden="true" />
)}
</ToastPrimitive.Close>
)
}
function ToastIcon({ type, still }: { type: string | undefined; still: boolean }) {
let icon: React.ReactNode = null
if (type === "success") {
icon = (
<CircleCheckIcon aria-hidden="true" />
)
}
if (type === "info") {
icon = (
<InfoIcon aria-hidden="true" />
)
}
if (type === "warning") {
icon = (
<TriangleAlertIcon aria-hidden="true" />
)
}
if (type === "error") {
icon = (
<OctagonXIcon className="text-destructive" aria-hidden="true" />
)
}
if (type === "loading") {
icon = (
<Loader2Icon
// A spin is a loop, so no token can time it: it stops instead — here
// for [data-motion="reduced"] as well as the OS setting.
className={still ? undefined : "animate-spin motion-reduce:animate-none in-data-[motion=reduced]:animate-none"}
aria-hidden="true"
/>
)
}
if (!icon) {
return null
}
return (
<span
data-slot="toast-icon"
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
>
{icon}
</span>
)
}
function ToastList({ position = "bottom-right" }: { position?: ToastPosition }) {
const { toasts } = ToastPrimitive.useToastManager()
// Read once, where the stack mounts, so a toast raised later is still from
// its first frame. Both requests for less motion count: the OS setting and
// a [data-motion="reduced"] above.
const reducedMotion = useReducedMotion()
// A toast is flicked off the edge it came in from, or sideways out of its
// corner.
const { side, align } = sidesOf(position)
const swipe: ("up" | "down" | "left" | "right")[] = [side === "top" ? "up" : "down", align === "left" ? "left" : "right"]
return toasts.map((toastItem) => (
<Toast key={toastItem.id} toast={toastItem} swipeDirection={swipe}>
<ToastContent>
<ToastIcon type={toastItem.type} still={reducedMotion} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<ToastTitle />
<ToastDescription />
</div>
<ToastAction />
<ToastClose />
</ToastContent>
</Toast>
))
}
function Toaster({
children,
toastManager = toast,
position = "bottom-right",
label = "Notifications",
...props
}: ToastPrimitive.Provider.Props & {
/** Where the stack sits; a top stack grows down and its toasts come in from above. */
position?: ToastPosition
/** The stack's region name. A second stack on the page needs one of its own. */
label?: string
}) {
return (
<ToastProvider toastManager={toastManager} {...props}>
{children}
<ToastPortal>
<ToastViewport position={position} aria-label={label}>
<ToastList position={position} />
</ToastViewport>
</ToastPortal>
</ToastProvider>
)
}
const createToastManager = ToastPrimitive.createToastManager
const useToastManager = ToastPrimitive.useToastManager
export type { ToastPosition }
export {
Toaster,
Toast,
ToastAction,
ToastClose,
ToastContent,
ToastDescription,
ToastPortal,
ToastProvider,
ToastTitle,
ToastViewport,
createToastManager,
toast,
useToastManager,
}