docs / core / toast

Toast

a brief message that announces something just happened. mount Toaster once, then fire toast() from anywhere, even outside React.

intensity · recedesrsc · client

provenance

shaped from sisu-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

Toaster mounts once in the root layout, the viewport portals itselffire from event handlers, async callbacks, non-React modulesnever two Toasters. two viewports means every toast twicenot for form errors. those belong inline, next to the field

a11y

the close button is labelled Dismiss · icons, dots and the countdown bar are aria-hidden · swipe down or right to dismissjest-axe

dependencies

@base-ui/react
live demo · try it out

toast() options

proptypedefaultnotes
titleReactNodethe headline. the only required field.
descriptionReactNodesupporting copy under the title.
type"success" | "warning" | "danger" | "info"the status icon, dot and tint. untyped toasts render plain.
durationnumberauto-dismiss in ms, drawn as a countdown bar. 0 disables it, for toasts that need a decision.
action{ label: string; onClick?: () => void }one inline action button. more than that is a Dialog.

Toaster

proptypedefaultnotes
limitnumberhow many toasts stack before the oldest collapses.
timeoutnumberthe default duration for toasts that do not set their own.

get it

npx shadcn add https://usva.build/r/toast.jsoncopy
source · components/ui/toast.tsxexactly what this command copiescopy
"use client";
import { Toast as Base } from "@base-ui/react/toast";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { useScopedTheme } from "./use-scoped-theme";

export type ToastType = "success" | "warning" | "danger" | "info";

export interface ToastActionOptions {
  label: string;
  onClick?: () => void;
}

export interface ToastOptions {
  title: React.ReactNode;
  description?: React.ReactNode;
  type?: ToastType;
  duration?: number;
  action?: ToastActionOptions;
}

export const toastManager = Base.createToastManager();

export function toast(options: ToastOptions): string {
  const { duration, action, ...rest } = options;
  return toastManager.add({
    ...rest,
    timeout: duration,
    actionProps: action
      ? { children: action.label, onClick: action.onClick }
      : undefined,
  });
}

export type NotifyOptions = Omit<ToastOptions, "title" | "type">;

function makeNotifier(type: ToastType) {
  return (title: React.ReactNode, options?: NotifyOptions): string =>
    toast({ ...options, title, type });
}

/**
 * Imperative sugar over `toast()`. Call from anywhere, including outside React:
 * `notify.success("Saved")`, `notify.error("Upload failed", { description })`.
 * Needs a mounted `Toaster` to render into, but not around the call site.
 */
export const notify = {
  success: makeNotifier("success"),
  warning: makeNotifier("warning"),
  danger: makeNotifier("danger"),
  error: makeNotifier("danger"),
  info: makeNotifier("info"),
};

export interface ToasterProps {
  limit?: number;
  timeout?: number;
}

interface ToneStyle {
  icon: React.ReactNode;
  text: string;
  dot: string;
  tint: string;
}

const toneStyles: Record<ToastType, ToneStyle> = {
  success: {
    icon: <CheckIcon />,
    text: "text-success",
    dot: "bg-success",
    tint: "bg-success/[0.06]",
  },
  warning: {
    icon: <AlertIcon />,
    text: "text-warning",
    dot: "bg-warning",
    tint: "bg-warning/[0.06]",
  },
  danger: {
    icon: <ErrorIcon />,
    text: "text-danger",
    dot: "bg-danger",
    tint: "bg-danger/[0.07]",
  },
  info: {
    icon: <InfoIcon />,
    text: "text-info",
    dot: "bg-info",
    tint: "bg-info/[0.06]",
  },
};

function ToastItem({ toast: item }: { toast: Base.Root.ToastObject }) {
  const tone = item.type ? toneStyles[item.type as ToastType] : undefined;
  const duration = typeof item.timeout === "number" ? item.timeout : 0;
  return (
    <Base.Root
      toast={item}
      swipeDirection={["down", "right"]}
      className={cn(
        "group/toast rim-light pointer-events-auto relative isolate flex w-full max-w-sm gap-3 overflow-hidden rounded-xl border border-border bg-overlay p-4 text-ink shadow-overlay",
        "transition-enter duration-slow ease-spring motion-reduce:transition-none motion-reduce:transform-none",
        "data-[starting-style]:translate-y-3 data-[starting-style]:opacity-0 data-[starting-style]:blur-[3px]",
        "data-[ending-style]:translate-x-4 data-[ending-style]:opacity-0 data-[ending-style]:duration-fast data-[ending-style]:ease-soft",
        "data-[swiping]:transition-none motion-reduce:data-[starting-style]:translate-none motion-reduce:data-[ending-style]:translate-none",
      )}
    >
      {tone ? (
        <>
          <span
            aria-hidden="true"
            className={cn(
              "pointer-events-none absolute inset-0 rounded-[inherit]",
              tone.tint,
            )}
          />
          <span
            aria-hidden="true"
            className={cn("relative mt-0.5 shrink-0", tone.text)}
          >
            {tone.icon}
          </span>
        </>
      ) : null}
      <div className="relative flex min-w-0 flex-1 flex-col gap-1">
        <div className="flex items-center gap-2">
          {tone ? (
            <span
              aria-hidden="true"
              className={cn("size-1.5 shrink-0 rounded-full", tone.dot)}
            />
          ) : null}
          <Base.Title className="text-sm font-semibold text-ink" />
        </div>
        <Base.Description className="text-sm text-muted" />
        <Base.Action
          className={cn(
            "mt-1 self-start rounded-md border border-border-strong px-2.5 py-1 text-xs font-medium text-ink outline-none",
            "transition-control duration-fast ease-soft motion-reduce:transition-none",
            "hover:bg-surface-2 focus-visible:border-transparent focus-visible:ring-focus",
            "active:scale-[0.98] motion-reduce:transform-none",
          )}
        />
      </div>
      <Base.Close
        aria-label="Dismiss"
        className={cn(
          "relative -m-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted outline-none",
          "before:absolute before:-inset-2.5 before:content-['']",
          "transition-control duration-fast ease-soft motion-reduce:transition-none motion-reduce:transform-none",
          "hover:bg-surface-2 hover:text-ink active:scale-[0.96] focus-visible:ring-focus",
        )}
      >
        <CloseIcon />
      </Base.Close>
      {duration > 0 ? (
        <span
          aria-hidden="true"
          style={
            { "--usva-toast-duration": `${duration}ms` } as React.CSSProperties
          }
          className={cn(
            "animate-toast-countdown pointer-events-none absolute inset-x-0 bottom-0 h-0.5 opacity-60 group-hover/toast:[animation-play-state:paused]",
            tone ? tone.dot : "bg-border-strong",
          )}
        />
      ) : null}
    </Base.Root>
  );
}

function ToastList() {
  const { toasts } = Base.useToastManager();
  return (
    <>
      {toasts.map((item) => (
        <ToastItem key={item.id} toast={item} />
      ))}
    </>
  );
}

/**
 * Mount once, anywhere in the tree. It does not wrap your app: `notify` and `toast`
 * talk to a module-scoped manager, not to React context, so the call site never has
 * to sit underneath this. Toasts fired before it mounts are dropped, not queued.
 */
export function Toaster({ limit, timeout }: ToasterProps) {
  const [probe, scopedTheme] = useScopedTheme();

  return (
    <Base.Provider toastManager={toastManager} limit={limit} timeout={timeout}>
      <span ref={probe} hidden />
      <Base.Portal>
        <Base.Viewport
          data-theme={scopedTheme}
          className="pointer-events-none fixed bottom-4 left-4 right-4 z-toast flex w-auto flex-col-reverse gap-2 sm:left-auto sm:w-full sm:max-w-sm"
        >
          <ToastList />
        </Base.Viewport>
      </Base.Portal>
    </Base.Provider>
  );
}
Toaster.displayName = "Toaster";

function CloseIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="16"
      height="16"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="m8.5 12 2.5 2.5 4.5-5" />
    </svg>
  );
}

function AlertIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
      <path d="M12 9v4M12 17h.01" />
    </svg>
  );
}

function ErrorIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="m15 9-6 6M9 9l6 6" />
    </svg>
  );
}

function InfoIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M12 11v5M12 8h.01" />
    </svg>
  );
}