For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Sonner

Toasts drawn as notification cards, powered by Sonner.

Installation

$ pnpm dlx shadcn@latest add @cobalt/sonner

Install theme-cobalt for the exact card, status and countdown colours.

The countdown bar runs on a keyframe, so add it to your global CSS. The registry adds it for you; this is the rule it inserts:

@keyframes sonner-countdown {
  from {
    transform: scaleX(1);
  }
  to {
    transform: scaleX(0);
  }
}

Usage

Render <Toaster /> once near the root of the app and call toast from this file, not from sonner, so the card, its status icons and the countdown bar are drawn. Sonner still owns the stack, the timers, the hover pause and the swipe.

import { Toaster, toast } from "@/registry/cobalt/ui/sonner";
 
export function Layout({ children }) {
  return (
    <>
      {children}
      <Toaster />
    </>
  );
}
 
toast("Draft saved", { description: "Your notes are up to date." });
toast.success("Changes published");
toast.error("Could not publish", { description: "Try again." });
toast.warning("Review required");
const id = toast.loading("Preparing export");
toast.update(id, { title: "Export ready", status: "success", duration: 5000 });
toast.promise(saveReport(), {
  loading: "Saving",
  success: "Saved",
  error: "Could not save",
});
toast("Delete this page?", {
  actions: [{ label: "Keep" }, { label: "Delete" }],
});

Options

OptionUse
descriptionA second line under the title.
statusneutral, info, success, warning, error or loading. Picks the icon and its colours.
iconA component or node in place of the status icon.
actionsSmall buttons under the message. Pressing one also dismisses the toast.
durationMilliseconds before it closes. Default 5000; Infinity keeps it until closed.
dismissibleShow the close button. Default true.
onDismissCalled once the toast has left the screen.
idReuse an id to replace a toast that is still showing.
toasterIdDraw only in the matching <Toaster id>, for a scoped preview or a second stack.

toast.success, toast.error, toast.info, toast.warning and toast.loading set the status for you. Loading stays open until updated or dismissed. toast.promise(promise, { loading, success, error }) updates a loading toast when the promise settles; it returns { id, unwrap } for dismissal or access to the original result. toast.update(id, options) changes a toast that is showing, and toast.dismiss(id) closes one, or all of them without an id.

Motion

A toast rises 12px, scales from 97% and clears 4px of blur in 250ms. It leaves by dropping 8px, scaling to 96% and blurring 3px in 180ms, while the older cards close the gap. With prefers-reduced-motion: reduce the transitions are off and the countdown bar holds at full width.

Source

"use client";

import {
  RiNotification3Fill,
  RiErrorWarningFill,
  RiCheckboxCircleFill,
  RiInformationFill,
  RiLoader2Line,
  RiAlertFill,
} from "@remixicon/react";
import { cn } from "cn";
import { useTheme } from "next-themes";
import * as React from "react";
import { Toaster as Sonner, toast as sonnerToast } from "sonner";
import type { ExternalToast, ToastT, ToasterProps } from "sonner";

import { Button } from "@/components/ui/button";

/**
 * Toast — fire-and-forget notifications drawn by Sonner:
 *
 *   toast("Changes saved");
 *   toast.error("Upload failed", { description: "The file is over 20MB." });
 *   const id = toast("Exporting…", { duration: Infinity });
 *   toast.update(id, { title: "Export ready", status: "success", duration: 4000 });
 *   toast.dismiss(id);
 *
 * Render one <Toaster /> near the root of the app. Sonner owns the stack,
 * the timers, the hover pause and the swipe, so the cards keep the same
 * height accounting and the same polite live region. The card, its status
 * icons, the countdown bar and the enter and exit motion are ours.
 */

type ToastId = number | string;
type ToastStatus =
  | "neutral"
  | "info"
  | "success"
  | "warning"
  | "error"
  | "loading";

type ToastIcon = React.ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

interface ToastAction {
  label: React.ReactNode;
  onClick?: () => void;
  variant?: React.ComponentProps<typeof Button>["variant"];
}

interface ToastOptions {
  /** Reuse an id to replace a toast that is still showing. */
  id?: ToastId;
  description?: React.ReactNode;
  status?: ToastStatus;
  /** Custom leading icon; defaults to the status icon. */
  icon?: ToastIcon | React.ReactNode;
  /** Small buttons under the message. Pressing one also dismisses the toast. */
  actions?: ToastAction[];
  /** Milliseconds before it dismisses itself; `Infinity` keeps it until closed. Default 5000. */
  duration?: number;
  /** Show the close button. Default true. */
  dismissible?: boolean;
  /** Called once the toast has left the screen. */
  onDismiss?: () => void;
  /** Draw only in the matching <Toaster id>. */
  toasterId?: string;
}

const DEFAULT_DURATION = 5000;

const STATUS_TYPE: Record<
  ToastStatus,
  "success" | "info" | "warning" | "error" | "loading" | undefined
> = {
  neutral: undefined,
  info: "info",
  success: "success",
  error: "error",
  warning: "warning",
  loading: "loading",
};

const TYPE_STATUS: Record<string, ToastStatus> = {
  info: "info",
  success: "success",
  error: "error",
  warning: "warning",
  loading: "loading",
};

/*
 * The card is Sonner's own element, so the motion has to be expressed on its
 * state attributes. Resting 12px low at 97% and 4px of blur becomes 0 in
 * 250ms; the exit drops 8px, scales to 96% and blurs 3px in 180ms, which
 * Sonner's 200ms unmount window covers. The height transition closes the gap
 * the leaving card leaves behind and runs a little longer.
 */
const TOAST_CLASS = [
  // A title on its own is centred against the icon; with a description the
  // text starts at the top edge of the icon, as in the source.
  "cn-toast relative flex w-full flex-wrap items-center gap-3 overflow-hidden has-data-[description]:items-start",
  "rounded-[16px] border border-border p-4 pe-11",
  "bg-popover text-sm text-popover-foreground",
  "shadow-[shadow:var(--shadow-dropdown,0_1px_1px_0_rgb(0_0_0/0.04),0_4px_4px_0_rgb(0_0_0/0.02))]",
  "[transition:transform_250ms_ease-out,opacity_250ms_ease-out,filter_250ms_ease-out,scale_250ms_ease-out,height_300ms_ease-out]!",
  "data-[mounted=false]:[--y:translateY(12px)]!",
  "data-[mounted=false]:scale-97",
  "data-[mounted=false]:blur-[4px]",
  "data-[removed=true]:[--y:translateY(calc(var(--lift)_*_var(--offset)_+_8px))]!",
  "data-[removed=true]:opacity-0!",
  "data-[removed=true]:scale-96",
  "data-[removed=true]:blur-[3px]",
  "data-[removed=true]:[transition:transform_180ms_ease-out,opacity_180ms_ease-out,filter_180ms_ease-out,scale_180ms_ease-out,height_200ms_ease-out]!",
  // Sonner moves the card with the pointer while it is being dragged.
  "data-[swiping=true]:[transition:none]!",
  // Repeated attribute so this outranks the state transitions above instead
  // of depending on source order.
  "motion-reduce:[&[data-sonner-toast][data-sonner-toast]]:[transition:none]!",
].join(" ");

const TITLE_CLASS = [
  "text-sm leading-5 font-medium text-foreground",
  // The countdown bar: full width along the bottom edge, shrinking from the
  // start edge. `--toast-duration` is set per toast, so a toast that never
  // dismisses gets a bar that is already empty.
  "after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-[3px]",
  "after:origin-left after:bg-[var(--notification-countdown,var(--primary))] after:content-['']",
  "after:animate-[sonner-countdown_var(--toast-duration,0ms)_linear_forwards]",
  "group-hover:after:[animation-play-state:paused]",
  "rtl:after:origin-right",
  "motion-reduce:after:animate-none",
].join(" ");

const CLOSE_CLASS = [
  "absolute end-3 top-3 z-10 flex size-5 shrink-0 cursor-pointer items-center",
  // Sonner restyles its own close button in dark mode with unlayered CSS, so
  // the surface and the colours have to win on importance.
  "justify-center rounded-full border-0 bg-[var(--surface-strong,var(--muted))]!",
  "text-muted-foreground! outline-none",
  "transition-colors! duration-150 ease hover:text-foreground!",
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
  "focus-visible:ring-offset-popover motion-reduce:transition-none",
].join(" ");

const TOAST_CLASSNAMES = {
  toast: TOAST_CLASS,
  // `relative` centres Sonner's loading spinner inside the circle.
  icon: "relative flex size-10 shrink-0 items-center justify-center rounded-full bg-[var(--surface-strong,var(--muted))] text-muted-foreground [&>svg]:size-5",
  info: "[&_[data-icon]]:bg-[var(--notification-information-background,var(--muted))] [&_[data-icon]]:text-[color:var(--notification-information-foreground,var(--primary))]",
  success:
    "[&_[data-icon]]:bg-[var(--notification-success-background,var(--muted))] [&_[data-icon]]:text-[color:var(--notification-success-foreground,var(--primary))]",
  error:
    "[&_[data-icon]]:bg-[var(--notification-error-background,var(--muted))] [&_[data-icon]]:text-[color:var(--notification-error-foreground,var(--destructive))]",
  warning: "[&_[data-icon]]:bg-muted [&_[data-icon]]:text-foreground",
  content: "flex min-w-0 flex-1 flex-col gap-1",
  title: TITLE_CLASS,
  description: "text-sm leading-5 text-muted-foreground!",
  closeButton: CLOSE_CLASS,
};

let nextToastId = 1;

function renderIcon(icon: ToastIcon | React.ReactNode): React.ReactNode {
  if (typeof icon === "function") {
    const Icon = icon;
    return <Icon aria-hidden className="size-5" />;
  }
  return icon;
}

function ToastActions({
  actions,
  id,
}: {
  actions: ToastAction[];
  id: ToastId;
}) {
  return (
    // Full width so it wraps under the message, with the card's 12px gap
    // pulled back to the 6px the actions row uses.
    <div
      className="-mt-1.5 flex w-full flex-wrap items-center gap-2"
      onPointerDown={(event) => event.stopPropagation()}
    >
      {actions.map((action, index) => (
        <Button
          key={index}
          size="sm"
          variant={action.variant ?? (index === 0 ? "secondary" : "default")}
          onClick={() => {
            action.onClick?.();
            sonnerToast.dismiss(id);
          }}
        >
          {action.label}
        </Button>
      ))}
    </div>
  );
}

function show(
  type: "success" | "info" | "warning" | "error" | "loading" | undefined,
  title: React.ReactNode,
  options: ToastOptions
): ToastId {
  nextToastId += 1;
  const id = options.id ?? `sonner-toast-${nextToastId}`;
  // A duration that is not a positive number keeps the toast until it is
  // closed, as in the source. The bar and the timer have to run for the same
  // time, so the duration is always explicit rather than left to the
  // toaster's default.
  const duration =
    options.duration === undefined
      ? type === "loading"
        ? Number.POSITIVE_INFINITY
        : DEFAULT_DURATION
      : options.duration > 0
        ? options.duration
        : Number.POSITIVE_INFINITY;
  const payload: ExternalToast = {
    id,
    duration,
    style: {
      "--toast-duration": Number.isFinite(duration) ? `${duration}ms` : "0ms",
    } as React.CSSProperties,
  };

  if (options.description !== undefined) {
    payload.description = options.description;
  }

  if (options.dismissible !== undefined) {
    payload.dismissible = options.dismissible;
    payload.closeButton = options.dismissible;
  }

  if (options.onDismiss) {
    payload.onDismiss = () => options.onDismiss?.();
  }

  if (options.toasterId !== undefined) {
    payload.toasterId = options.toasterId;
  }

  if (options.icon !== undefined) {
    payload.icon = renderIcon(options.icon);
  } else if (!type) {
    payload.icon = <RiNotification3Fill aria-hidden className="size-5" />;
  }

  if (options.actions && options.actions.length > 0) {
    payload.action = <ToastActions actions={options.actions} id={id} />;
  }

  if (type === "success") {
    sonnerToast.success(title, payload);
  } else if (type === "info") {
    sonnerToast.info(title, payload);
  } else if (type === "warning") {
    sonnerToast.warning(title, payload);
  } else if (type === "loading") {
    sonnerToast.loading(title, payload);
  } else if (type === "error") {
    sonnerToast.error(title, payload);
  } else {
    sonnerToast(title, payload);
  }

  return id;
}

function toast(title: React.ReactNode, options: ToastOptions = {}): ToastId {
  return show(STATUS_TYPE[options.status ?? "neutral"], title, options);
}

toast.success = (
  title: React.ReactNode,
  options?: Omit<ToastOptions, "status">
) => show("success", title, options ?? {});

toast.error = (
  title: React.ReactNode,
  options?: Omit<ToastOptions, "status">
) => show("error", title, options ?? {});

toast.info = (title: React.ReactNode, options?: Omit<ToastOptions, "status">) =>
  show("info", title, options ?? {});

toast.warning = (
  title: React.ReactNode,
  options?: Omit<ToastOptions, "status">
) => show("warning", title, options ?? {});

toast.loading = (
  title: React.ReactNode,
  options?: Omit<ToastOptions, "status">
) => show("loading", title, options ?? {});

toast.promise = <T,>(
  promise: Promise<T> | (() => Promise<T>),
  options: ToastOptions & {
    loading: React.ReactNode;
    success: React.ReactNode | ((value: T) => React.ReactNode);
    error: React.ReactNode | ((error: unknown) => React.ReactNode);
  }
) => {
  const { loading, success, error: failure, ...rest } = options;
  const id = show("loading", loading, rest);
  const result = Promise.resolve().then(() =>
    typeof promise === "function" ? promise() : promise
  );
  const settle = async () => {
    let value: T;
    try {
      value = await result;
    } catch (error: unknown) {
      show("error", typeof failure === "function" ? failure(error) : failure, {
        ...rest,
        id,
      });
      return;
    }
    show("success", typeof success === "function" ? success(value) : success, {
      ...rest,
      id,
    });
  };
  void settle();
  return { id, unwrap: () => result };
};

/** Change a toast that is showing. */
toast.update = (
  id: ToastId,
  options: ToastOptions & { title?: React.ReactNode }
): void => {
  const current = sonnerToast
    .getToasts()
    .find((item): item is ToastT => "title" in item && item.id === id);
  if (!current) {
    return;
  }
  const { title, status, icon, ...rest } = options;
  const resolved = status ?? TYPE_STATUS[String(current.type)] ?? "neutral";
  const currentTitle =
    typeof current.title === "function" ? current.title() : current.title;
  show(STATUS_TYPE[resolved], title ?? currentTitle, {
    ...rest,
    id,
    duration: options.duration ?? current.duration,
    icon: icon ?? (status === undefined ? current.icon : undefined),
  });
};

/** Dismiss one toast, or every toast when called without an id. */
toast.dismiss = (id?: ToastId) => {
  sonnerToast.dismiss(id);
};

function Toaster({
  className,
  style,
  toastOptions,
  icons,
  theme,
  position = "bottom-right",
  expand = true,
  gap = 12,
  visibleToasts = 3,
  offset = 24,
  mobileOffset = 12,
  closeButton = true,
  ...props
}: ToasterProps) {
  const { theme: appTheme = "system" } = useTheme();

  return (
    <Sonner
      theme={(theme ?? appTheme) as ToasterProps["theme"]}
      position={position}
      expand={expand}
      gap={gap}
      visibleToasts={visibleToasts}
      offset={offset}
      mobileOffset={mobileOffset}
      closeButton={closeButton}
      className={cn("group", className)}
      // The source stack is as wide as a 400px card, or the viewport minus
      // 12px of gutter on a phone.
      style={
        {
          "--width": "min(400px, calc(100vw - 24px))",
          ...style,
        } as React.CSSProperties
      }
      icons={{
        error: <RiErrorWarningFill aria-hidden className="size-5" />,
        info: <RiInformationFill aria-hidden className="size-5" />,
        loading: (
          <RiLoader2Line
            aria-hidden
            className="size-5 animate-spin motion-reduce:animate-none"
          />
        ),
        success: <RiCheckboxCircleFill aria-hidden className="size-5" />,
        warning: <RiAlertFill aria-hidden className="size-5" />,
        // The close glyph is drawn at 10.8px with a 2px round stroke, as in
        // the source's 20px close button.
        close: (
          <svg
            aria-hidden
            width="10.8"
            height="10.8"
            viewBox="0 0 10.8 10.8"
            fill="none"
          >
            <path
              d="M2 2L8.8 8.8M8.8 2L2 8.8"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
            />
          </svg>
        ),
        ...icons,
      }}
      toastOptions={{
        unstyled: true,
        closeButtonAriaLabel: "Dismiss notification",
        ...toastOptions,
        classNames: {
          ...TOAST_CLASSNAMES,
          ...toastOptions?.classNames,
        },
      }}
      {...props}
    />
  );
}

export { Toaster, toast };
export type { ToastAction, ToastId, ToastOptions, ToastStatus };