Skip to contentVibraUI
Feedback & status

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/notify

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
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.descriptionReact.ReactNode—A second line under the title.
NotifyOptions.action{ label: string; onClick: () => void }—One button on the toast — "Undo", "Retry", "View run".
NotifyOptions.durationnumber5000, or 0 for loadingMilliseconds on screen; 0 keeps the toast up until it is dismissed.
NotifyOptions.idstring | number—Reuse an id and the matching toast updates in place instead of stacking.

Dependencies

Source

lib/notify.ts
"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)),
}