Notify
One call per intent — success, error, warning, info, loading, promise — over the toast manager.
Client-only, and built on the Base UI toast manager that ships with shadcn's toast, not on sonner. Mount Toaster once in the root layout and every call works from anywhere on the client. A loading toast stays up until it is dismissed or replaced. Reuse an id and the matching toast updates in place instead of stacking. promise returns void and swallows its own copy of a rejection, so the failure is reported on the toast while the promise stays the caller's to handle.
Install
$
npx shadcn@latest add @vibra/notifyNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import { Button } from "@/components/ui/button"
import { notify } from "@/lib/notify"
/** Stands in for the export job the promise toast would follow. */
function fakeExport() {
return new Promise<{ rows: number }>((resolve) => setTimeout(() => resolve({ rows: 12480 }), 1600))
}
export default function NotifyDemo() {
return (
<div className="flex w-full max-w-md flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => notify.success("Dashboard saved", { description: "Shared with 4 people." })}
>
Success
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
notify.error("Ingest failed", {
description: "The 14:00 run stopped after 12 seconds.",
action: { label: "Retry", onClick: () => notify.info("Retrying the 14:00 run") },
})
}
>
Error with action
</Button>
<Button
variant="outline"
size="sm"
onClick={() => notify.warning("92% of the row limit used")}
>
Warning
</Button>
<Button variant="outline" size="sm" onClick={() => notify.info("Rates refreshed")}>
Info
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
notify.promise(fakeExport(), {
loading: "Exporting revenue…",
success: (data) => `Exported ${data.rows.toLocaleString("en-US")} rows`,
error: "Export failed",
})
}
>
Promise
</Button>
<Button variant="ghost" size="sm" onClick={() => notify.dismiss()}>
Dismiss all
</Button>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| notify.success | (title: React.ReactNode, opts?: NotifyOptions) => string | number | — | Raises a success toast and returns its id. |
| notify.error | (title: React.ReactNode, opts?: NotifyOptions) => string | number | — | Raises an error toast and returns its id. |
| notify.warning | (title: React.ReactNode, opts?: NotifyOptions) => string | number | — | Raises a warning toast and returns its id. |
| notify.info | (title: React.ReactNode, opts?: NotifyOptions) => string | number | — | Raises an informational toast and returns its id. |
| notify.loading | (title: React.ReactNode, opts?: NotifyOptions) => string | number | — | Raises a spinner toast that stays until it is dismissed or replaced by its id. |
| notify.promise | <T>(promise: Promise<T>, msgs: { loading: React.ReactNode; success: React.ReactNode | ((data: T) => React.ReactNode); error: React.ReactNode | ((err: unknown) => React.ReactNode) }) => void | — | Shows a loading toast and swaps it for the success or error message when the promise settles. |
| notify.dismiss | (id?: string | number) => void | — | Closes one toast by id, or every toast when called with none. |
| NotifyOptions.description | React.ReactNode | — | A second line under the title. |
| NotifyOptions.action | { label: string; onClick: () => void } | — | One button on the toast — "Undo", "Retry", "View run". |
| NotifyOptions.duration | number | 5000, or 0 for loading | Milliseconds on screen; 0 keeps the toast up until it is dismissed. |
| NotifyOptions.id | string | number | — | Reuse an id and the matching toast updates in place instead of stacking. |
Dependencies
Registry
Source
"use client"
import type * as React from "react"
import { toast } from "@/components/ui/toast"
export type NotifyOptions = {
description?: React.ReactNode
/** One button on the toast — "Undo", "Retry", "View run". */
action?: { label: string; onClick: () => void }
/** Milliseconds on screen. 0 keeps the toast up until it is dismissed. */
duration?: number
/** Reuse an id and the matching toast updates in place instead of stacking. */
id?: string | number
}
export type NotifyPromiseMessages<T> = {
loading: React.ReactNode
success: React.ReactNode | ((data: T) => React.ReactNode)
error: React.ReactNode | ((err: unknown) => React.ReactNode)
}
type NotifyLevel = "success" | "error" | "warning" | "info" | "loading"
function push(type: NotifyLevel, title: React.ReactNode, options: NotifyOptions = {}): string {
return toast.add({
id: options.id === undefined ? undefined : String(options.id),
title,
description: options.description,
type,
// Loading has no natural end: it stays until the work replaces or dismisses it.
timeout: options.duration ?? (type === "loading" ? 0 : undefined),
actionProps: options.action
? { children: options.action.label, onClick: options.action.onClick }
: undefined,
})
}
/** A bare node becomes the toast's title; a function is resolved with the settled value first. */
function asToastOptions<T>(message: React.ReactNode | ((value: T) => React.ReactNode)) {
return typeof message === "function"
? (value: T) => ({ title: message(value) })
: { title: message }
}
export type Notify = {
success: (title: React.ReactNode, opts?: NotifyOptions) => string | number
error: (title: React.ReactNode, opts?: NotifyOptions) => string | number
warning: (title: React.ReactNode, opts?: NotifyOptions) => string | number
info: (title: React.ReactNode, opts?: NotifyOptions) => string | number
loading: (title: React.ReactNode, opts?: NotifyOptions) => string | number
promise: <T>(promise: Promise<T>, msgs: NotifyPromiseMessages<T>) => void
dismiss: (id?: string | number) => void
}
/**
* The toast manager, one call per intent. Mount <Toaster /> once in the root
* layout and every one of these works from anywhere on the client.
*/
export const notify: Notify = {
success: (title, opts) => push("success", title, opts),
error: (title, opts) => push("error", title, opts),
warning: (title, opts) => push("warning", title, opts),
info: (title, opts) => push("info", title, opts),
loading: (title, opts) => push("loading", title, opts),
promise: (promise, msgs) => {
toast
.promise(promise, {
loading: { title: msgs.loading },
success: asToastOptions(msgs.success),
error: asToastOptions(msgs.error),
})
// The toast has already reported the failure; the promise is still the
// caller's to handle, so swallow this copy rather than leave it unhandled.
.catch(() => {})
},
dismiss: (id) => toast.close(id === undefined ? undefined : String(id)),
}