Bloom Logo

Toast

A rich feedback notification popover with status accent bars, background icons, spinner loaders, progress indicator timers, and interactive action buttons.

importimport { Toast, toast } from "@/components/ui/toast/toast";
$npx @bloomui-react/cli add toast
toast.tsx
"use client";

import { Icon } from "@iconify/react";
import type * as React from "react";
import { Toaster as SonnerToaster, toast as sonnerToast } from "sonner";
import { Spinner, type SpinnerVariant } from "@/components/ui/spinner/spinner";
import { designRadius } from "@/lib/design-system";
import { cn } from "@/lib/utils";

export interface ToastProps {
  theme?: "light" | "dark" | "system";
  position?:
    | "top-left"
    | "top-right"
    | "bottom-left"
    | "bottom-right"
    | "top-center"
    | "bottom-center";
}

const progressStyle = `
@keyframes toastShrinkWidth {
  from { width: 100%; }
  to { width: 0%; }
}
`;

export function Toast({
  theme = "system",
  position = "bottom-right",
}: ToastProps) {
  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: progressStyle }} />
      <SonnerToaster
        theme={theme}
        position={position}
        toastOptions={{
          style: {
            background: "transparent",
            border: "none",
            boxShadow: "none",
            padding: 0,
          },
        }}
      />
    </>
  );
}

export type ToastVariant = "default" | "bordered" | "bar";
export type ToastSize = "sm" | "md" | "lg";
export type ToastRadius =
  | "none"
  | "xs"
  | "sm"
  | "md"
  | "lg"
  | "xl"
  | "2xl"
  | "3xl"
  | "full";

export interface ToastOptions {
  description?: React.ReactNode;
  action?: {
    label: string;
    onClick: () => void;
  };
  duration?: number;
  spinnerVariant?: SpinnerVariant;
  variant?: ToastVariant;
  size?: ToastSize;
  radius?: ToastRadius;
  richColors?: boolean;
  showBgIcon?: boolean;
  showProgress?: boolean;
  id?: string | number;
}

const createCustomToast = (
  type: "success" | "error" | "warning" | "info" | "default" | "loading",
  title: React.ReactNode,
  options?: ToastOptions,
) => {
  const duration = type === "loading" ? 100000 : options?.duration || 4000;
  const activeVariant = options?.variant || "default";
  const activeSize = options?.size || "md";
  const activeRadius = options?.radius || "xl";

  const iconMap = {
    success: {
      icon: "hugeicons:checkmark-circle-02",
      bg: "bg-success/10 text-success border-success/20",
      bar: "bg-success",
      richBg: "bg-success text-white border-transparent",
      borderClass: "border-success",
    },
    error: {
      icon: "hugeicons:alert-circle",
      bg: "bg-danger/10 text-danger border-danger/20",
      bar: "bg-danger",
      richBg: "bg-danger text-white border-transparent",
      borderClass: "border-danger",
    },
    warning: {
      icon: "hugeicons:alert-02",
      bg: "bg-warning/10 text-warning border-warning/20",
      bar: "bg-warning",
      richBg: "bg-warning text-white border-transparent",
      borderClass: "border-warning",
    },
    info: {
      icon: "hugeicons:information-circle",
      bg: "bg-primary/10 text-primary border-primary/20",
      bar: "bg-primary",
      richBg: "bg-primary text-white border-transparent",
      borderClass: "border-primary",
    },
    loading: {
      icon: null,
      bg: "bg-primary/10 text-primary border-primary/20",
      bar: "bg-primary",
      richBg: "bg-primary text-white border-transparent",
      borderClass: "border-primary",
    },
    default: {
      icon: "hugeicons:notification-01",
      bg: "bg-zinc-100 dark:bg-zinc-800 text-zinc-650 dark:text-zinc-350 border-zinc-200 dark:border-zinc-700",
      bar: "bg-zinc-400 dark:bg-zinc-650",
      richBg:
        "bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 border-transparent",
      borderClass: "border-zinc-300 dark:border-zinc-700",
    },
  };

  const style = iconMap[type];

  const sizeMap = {
    sm: {
      width: "w-72 max-w-xs p-3",
      iconBox: "size-7 rounded-lg",
      iconSize: "size-4.5",
      title: "text-xs",
      desc: "text-[10px] mt-0.5",
    },
    md: {
      width: "w-85 max-w-sm p-4",
      iconBox: "size-9 rounded-xl",
      iconSize: "size-5",
      title: "text-sm",
      desc: "text-xs mt-1",
    },
    lg: {
      width: "w-96 max-w-md p-5",
      iconBox: "size-11 rounded-2xl",
      iconSize: "size-6",
      title: "text-base",
      desc: "text-sm mt-1.5",
    },
  };

  const sStyle = sizeMap[activeSize];

  let containerBgBorder =
    "bg-white/95 dark:bg-zinc-900/95 border-zinc-200/80 dark:border-zinc-800/80";
  let textColor = "text-zinc-900 dark:text-zinc-100";
  let titleColor = "text-zinc-900 dark:text-zinc-100";
  let descColor = "text-zinc-500 dark:text-zinc-400";

  if (options?.richColors) {
    containerBgBorder = style.richBg;
    textColor = "text-white";
    titleColor = "text-white";
    descColor = "text-white/85";
  } else {
    switch (activeVariant) {
      case "bordered":
        containerBgBorder = cn(
          "bg-white dark:bg-zinc-900 border-2",
          style.borderClass,
        );
        break;
      case "bar":
        containerBgBorder =
          "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 pl-5";
        break;
      default:
        containerBgBorder =
          "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800";
        break;
    }
  }

  const leftBarVisible = activeVariant === "bar";
  const showSmallIcon = !options?.showBgIcon;
  const radiusClass = designRadius[activeRadius] || "rounded-2xl";

  const getToastId = (val: any) => {
    if (typeof val === "object" && val !== null && "id" in val) {
      return val.id;
    }
    return val;
  };

  return sonnerToast.custom(
    (t) => (
      <div
        className={cn(
          "relative flex gap-3.5 border shadow-2xl transition-all duration-300 overflow-hidden select-none pointer-events-auto",
          options?.description ? "items-start" : "items-center",
          sStyle.width,
          containerBgBorder,
          textColor,
          radiusClass,
        )}
      >
        {options?.showBgIcon && style.icon && (
          <Icon
            icon={style.icon}
            className="absolute -right-4 -bottom-4 size-24 opacity-[0.08] pointer-events-none rotate-12 text-current"
          />
        )}

        {leftBarVisible && !options?.richColors && (
          <div
            className={cn("absolute left-0 top-0 bottom-0 w-1.5", style.bar)}
          />
        )}

        {showSmallIcon && (
          <div
            className={cn(
              "flex items-center justify-center border shrink-0 transition-all duration-300 pointer-events-none",
              sStyle.iconBox,
              options?.richColors
                ? "bg-white/20 border-transparent text-white"
                : style.bg,
            )}
          >
            {type === "loading" ? (
              <Spinner
                variant={options?.spinnerVariant || "default"}
                color={options?.richColors ? "default" : "primary"}
                size="sm"
              />
            ) : (
              <Icon icon={style.icon || ""} className={sStyle.iconSize} />
            )}
          </div>
        )}

        <div className="flex-1 min-w-0 pr-5">
          <h5
            className={cn(
              "font-bold leading-tight pointer-events-none",
              sStyle.title,
              titleColor,
            )}
          >
            {title}
          </h5>
          {options?.description && (
            <p
              className={cn(
                "leading-relaxed pointer-events-none",
                sStyle.desc,
                descColor,
              )}
            >
              {options.description}
            </p>
          )}
          {options?.action && (
            <button
              type="button"
              onClick={() => {
                options.action?.onClick();
                sonnerToast.dismiss(getToastId(t));
              }}
              className={cn(
                "mt-2 text-xs font-extrabold transition-colors underline cursor-pointer pointer-events-auto relative z-50",
                options?.richColors
                  ? "text-white hover:text-white/80"
                  : "text-sky-500 hover:text-sky-600 dark:hover:text-sky-400",
              )}
            >
              {options.action.label}
            </button>
          )}
        </div>

        {options?.showProgress && (
          <div
            className={cn(
              "absolute bottom-0 left-0 right-0 h-1 transition-all pointer-events-none",
              options?.richColors ? "bg-white/45" : style.bar,
            )}
            style={{
              animation: `toastShrinkWidth ${duration}ms linear forwards`,
            }}
          />
        )}
      </div>
    ),
    {
      id: options?.id,
      duration: duration,
    },
  );
};

export const toast = Object.assign(
  (title: React.ReactNode, options?: ToastOptions) =>
    createCustomToast("default", title, options),
  {
    success: (title: React.ReactNode, options?: ToastOptions) =>
      createCustomToast("success", title, options),
    error: (title: React.ReactNode, options?: ToastOptions) =>
      createCustomToast("error", title, options),
    warning: (title: React.ReactNode, options?: ToastOptions) =>
      createCustomToast("warning", title, options),
    info: (title: React.ReactNode, options?: ToastOptions) =>
      createCustomToast("info", title, options),
    loading: (title: React.ReactNode, options?: ToastOptions) =>
      createCustomToast("loading", title, options),
    dismiss: (toastId?: string | number) => sonnerToast.dismiss(toastId),
  },
);

Default

Standard clean notification toast with title and description.

Variants

Choose from default clean layout, bordered outline style, or bar accent highlights on the left.

variant: default | bordered | bar

Sizes

Toasts support small, medium, or large sizes altering widths and inner layout spaces.

size: sm | md | lg

Radius

Configure standard border-radius styles ranging from square to fully rounded nodes.

radius: none | sm | md | lg | xl | 2xl | 3xl | full

Rich Colors

Fills the entire background color of the toast matching the semantic status color.

richColors: boolean

Background Icon Decoration

Fades a large low-opacity decorative status icon in the background of the toast.

showBgIcon: boolean

Timer Progress Bar

Render a timer indicator bar showing remaining duration.

showProgress: boolean

Interactive Action Toast

Include clickable inline action callbacks inside notifications.

Loading Toast Variations

Trigger animated loading spinner toasts, and dynamically update them to success or error results after asynchronous tasks.

toast.loading(title, options)id?: string | number

Custom Spinner Variants

Choose custom spinner animation styles for loading toasts using spinnerVariant (dots, bars, pulse, ring, gradient).

spinnerVariant?: dots | bars | pulse | ring | gradient
API Reference
PropTypeDefaultDescription
variant'default' | 'bordered' | 'bar''default'The visual style layout variant.
size'sm' | 'md' | 'lg''md'Sizing coordinates and text sizes.
radius'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full''xl'Corner border-radius rounding token.
richColorsbooleanfalseFills the whole background card matching status color.
showBgIconbooleanfalseRenders a huge faded background status icon decoration.
showProgressbooleanfalseDisplays a closing progress line.