Skip to content
Seamless UI

Toast

Save

Brief notifications built on Base UI Toast, with success, error, warning, info, and loading types, sliding in from their screen edge.

Category
Core
Version
0.2.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Toast preview

Installation

npx @seamlessui/cli add toast

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add toast --dry-run

Usage

toast-example.tsx
"use client";

import { ToastProvider, useToast } from "@/components/seamless/ui/toast";

// Same recipe as the Button component's secondary variant, inlined so this example has no other dependency.
const button =
  "inline-flex h-9 items-center justify-center rounded-lg border border-zinc-950/12 bg-white px-3.5 text-sm font-medium text-zinc-950 select-none " +
  "transition-[background-color,border-color,scale] duration-150 ease-out active:duration-100 motion-safe:active:scale-[0.97] motion-reduce:transition-none " +
  "hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 " +
  "dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06]";

export default function ToastExample() {
  return (
    <ToastProvider>
      <ToastButtons />
    </ToastProvider>
  );
}

function ToastButtons() {
  const toast = useToast();
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <button type="button" className={button} onClick={() => toast.success("Changes saved", { description: "Your profile is up to date." })}>
        Save
      </button>
      <button
        type="button"
        className={button}
        onClick={() =>
          toast.error("Upload failed", {
            description: "The file is larger than 10 MB.",
            actionProps: { children: "Try again", onClick: () => toast.info("Retrying upload…") },
          })
        }
      >
        Upload
      </button>
      <button
        type="button"
        className={button}
        onClick={() =>
          toast.promise(new Promise((resolve) => window.setTimeout(resolve, 1800)), {
            loading: "Deploying to production…",
            success: { title: "Deployed", description: "acme.dev is serving build 4f2a91c." },
            error: "Deploy failed",
          })
        }
      >
        Deploy
      </button>
    </div>
  );
}

API

PropTypeDefaultDescription
ToastProvider position"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right""bottom-right"Where toasts appear. Swipe-to-dismiss follows the nearest edges.
ToastProvider timeoutnumber5000Default time in ms before a toast closes. 0 keeps toasts until dismissed. Timers pause on hover and focus.
ToastProvider limitnumber3Most toasts shown at once; older ones are hidden until newer ones close.
ToastProvider toastManagerToastManager—A manager from createToastManager(), for adding toasts outside React components.
ToastProvider closeLabelstring"Dismiss notification"Accessible name of each toast's dismiss button.
useToast(){ add, success, error, warning, info, close, update, promise, toasts }—Adds and manages toasts. success/error/warning/info take a title and optional options; add takes full options.
ToastOptions type"success" | "error" | "warning" | "info" | "loading"—Sets the status glyph (check, cross, exclamation, info, or spinner) and a type label that is read before the title by screen readers. Omit for a plain toast.
ToastOptions title / descriptionReactNode—Toast heading and body text.
ToastOptions priority"low" | "high""low"low is announced politely; high is announced immediately (role="alert"). Use high for errors that need attention.
ToastOptions actionPropsComponentProps<"button">—Renders an action button, for example { children: "Undo", onClick }.
ToastOptions timeoutnumber—Per-toast override of the provider timeout.
createToastManager() => ToastManager—Re-exported from Base UI for toasting from outside React.

Accessibility

  • Toasts render in a labelled region ("Notifications") that is an aria-live polite region, so new toasts are announced; high-priority toasts are announced through role="alert".
  • Every typed toast pairs a distinct glyph shape with its title, so color is never the only signal, and its type (Success, Error, Warning, Info, In progress) is read before the title as part of the accessible name.
  • F6 moves focus to the toast region; each toast is focusable, Escape dismisses the focused toast, and the dismiss button has an accessible name.
  • Auto-dismiss timers pause while the pointer is over the toasts or focus is inside them, and while the window is in the background.
  • Toasts enter from their screen edge in 260ms and leave with a smaller 180ms move; swipe dismissal follows the pointer. With reduced motion the slide and fade are removed and the loading spinner stops; toasts appear and disappear immediately and stay fully readable.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/ui/toast.tsxtoast
  • npm packages: @base-ui/react@^1.9.0React and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/toast/0.2.0.json.

ui/toast.tsxShow
ui/toast.tsx
"use client";

import { Toast as BaseToast, type ToastManagerAddOptions, type ToastObject } from "@base-ui/react/toast";
import type { ReactNode } from "react";

function cx(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(" ");
}

export type ToastType = "success" | "error" | "warning" | "info" | "loading";

/** Options for a toast. `title`, `description`, `type`, `timeout`, `priority`, and `actionProps` are the common ones. */
export interface ToastOptions extends Omit<ToastManagerAddOptions<object>, "type"> {
  type?: ToastType | undefined;
}

type ShortcutOptions = Omit<ToastOptions, "type" | "title">;

const types: Record<ToastType, { label: string; tone: string }> = {
  success: { label: "Success", tone: "text-emerald-600 dark:text-emerald-400" },
  error: { label: "Error", tone: "text-red-600 dark:text-red-400" },
  warning: { label: "Warning", tone: "text-amber-600 dark:text-amber-400" },
  info: { label: "Info", tone: "text-sky-600 dark:text-sky-400" },
  loading: { label: "In progress", tone: "text-zinc-500 dark:text-zinc-400" },
};

// Toasts enter from the screen edge they sit on (16px) and leave with a smaller move (6px).
// Swipe dismissal keeps travelling in the swipe direction while it fades.
const fromBottom =
  "data-[starting-style]:[translate:0_1rem] data-[ending-style]:[translate:var(--toast-swipe-movement-x,0px)_calc(var(--toast-swipe-movement-y,0px)+0.375rem)]";
const fromTop =
  "data-[starting-style]:[translate:0_-1rem] data-[ending-style]:[translate:var(--toast-swipe-movement-x,0px)_calc(var(--toast-swipe-movement-y,0px)-0.375rem)]";

const positions = {
  "top-left": { viewport: "top-4 left-4 flex-col", swipe: ["up", "left"], motion: fromTop },
  "top-center": { viewport: "top-4 left-1/2 -translate-x-1/2 flex-col", swipe: ["up"], motion: fromTop },
  "top-right": { viewport: "top-4 right-4 flex-col", swipe: ["up", "right"], motion: fromTop },
  "bottom-left": { viewport: "bottom-4 left-4 flex-col-reverse", swipe: ["down", "left"], motion: fromBottom },
  "bottom-center": { viewport: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse", swipe: ["down"], motion: fromBottom },
  "bottom-right": { viewport: "right-4 bottom-4 flex-col-reverse", swipe: ["down", "right"], motion: fromBottom },
} as const satisfies Record<
  string,
  { viewport: string; swipe: readonly ("up" | "down" | "left" | "right")[]; motion: string }
>;

export type ToastPosition = keyof typeof positions;

/** A manager you can call outside React. Pass it to `<ToastProvider toastManager={...}>`. */
export const createToastManager = BaseToast.createToastManager;

export interface ToastProviderProps {
  children?: ReactNode;
  /** Default time in ms before a toast closes. `0` keeps toasts open until dismissed. */
  timeout?: number;
  /** Most toasts shown at once. Older ones are hidden until newer ones close. */
  limit?: number;
  /** Where the toasts appear on screen. */
  position?: ToastPosition;
  /** A manager from `createToastManager()`, for adding toasts outside React components. */
  toastManager?: BaseToast.Provider.Props["toastManager"];
  /** Accessible name of each toast's dismiss button. */
  closeLabel?: string;
  /** Classes for the fixed viewport that holds the toasts. */
  viewportClassName?: string;
}

/**
 * Holds toast state and renders the viewport. Wrap your app once; call `useToast()` anywhere inside.
 */
export function ToastProvider({
  children,
  timeout = 5000,
  limit = 3,
  position = "bottom-right",
  toastManager,
  closeLabel = "Dismiss notification",
  viewportClassName,
}: ToastProviderProps) {
  const placement = positions[position];
  return (
    <BaseToast.Provider timeout={timeout} limit={limit} toastManager={toastManager}>
      {children}
      <BaseToast.Portal>
        <BaseToast.Viewport
          className={cx(
            "fixed z-[60] flex w-[calc(100vw-2rem)] max-w-[22.5rem] gap-2 outline-none",
            "focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-sky-500",
            placement.viewport,
            viewportClassName,
          )}
        >
          <ToastList swipe={[...placement.swipe]} motion={placement.motion} closeLabel={closeLabel} />
        </BaseToast.Viewport>
      </BaseToast.Portal>
    </BaseToast.Provider>
  );
}

type SwipeDirection = "up" | "down" | "left" | "right";

function ToastList({ swipe, motion, closeLabel }: { swipe: SwipeDirection[]; motion: string; closeLabel: string }) {
  const { toasts } = BaseToast.useToastManager();
  return toasts.map((toast) => <ToastItem key={toast.id} toast={toast} swipe={swipe} motion={motion} closeLabel={closeLabel} />);
}

function ToastItem({
  toast,
  swipe,
  motion,
  closeLabel,
}: {
  toast: ToastObject<object>;
  swipe: SwipeDirection[];
  motion: string;
  closeLabel: string;
}) {
  const meta = isToastType(toast.type) ? types[toast.type] : null;

  return (
    <BaseToast.Root
      toast={toast}
      swipeDirection={swipe}
      className={cx(
        "relative flex w-full items-start gap-3 rounded-xl p-4 pr-11 select-none",
        "border border-zinc-950/10 bg-white text-zinc-950 shadow-[0_12px_32px_-8px_rgb(0_0_0/0.18)]",
        "dark:border-white/15 dark:bg-black dark:text-white dark:shadow-[0_16px_48px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)]",
        "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
        // The swipe offset is applied live; the enter and exit offsets come from `motion`.
        "[translate:var(--toast-swipe-movement-x,0px)_var(--toast-swipe-movement-y,0px)]",
        "transition-[opacity,translate] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
        "data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[ending-style]:duration-180",
        motion,
        "data-[ending-style]:data-[swipe-direction=right]:[translate:calc(var(--toast-swipe-movement-x,0px)+2rem)_var(--toast-swipe-movement-y,0px)]",
        "data-[ending-style]:data-[swipe-direction=left]:[translate:calc(var(--toast-swipe-movement-x,0px)-2rem)_var(--toast-swipe-movement-y,0px)]",
        "data-[ending-style]:data-[swipe-direction=down]:[translate:var(--toast-swipe-movement-x,0px)_calc(var(--toast-swipe-movement-y,0px)+2rem)]",
        "data-[ending-style]:data-[swipe-direction=up]:[translate:var(--toast-swipe-movement-x,0px)_calc(var(--toast-swipe-movement-y,0px)-2rem)]",
        "data-[swiping]:transition-none data-[limited]:hidden",
      )}
    >
      {/* Keyed by type so a loading toast that resolves replays the glyph's entry. */}
      {toast.type && isToastType(toast.type) ? <ToastIcon key={toast.type} type={toast.type} /> : null}
      <div className="grid min-w-0 flex-1 gap-0.5">
        <BaseToast.Title className="text-sm leading-5 font-medium">
          {meta ? (
            <>
              <span className="sr-only">{meta.label}: </span>
              {toast.title}
            </>
          ) : undefined}
        </BaseToast.Title>
        <BaseToast.Description className="text-sm leading-5 text-zinc-600 dark:text-zinc-400" />
        <BaseToast.Action
          className={cx(
            "mt-2.5 inline-flex h-7 w-fit items-center rounded-lg border border-zinc-950/12 bg-white px-2.5 text-xs font-medium text-zinc-950",
            "transition-[background-color,border-color,scale] duration-150 ease-out active:duration-100 motion-reduce:transition-none",
            "hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] motion-safe:active:scale-[0.97]",
            "dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06]",
            "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
          )}
        />
      </div>
      <BaseToast.Close
        aria-label={closeLabel}
        className={cx(
          "absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-zinc-400",
          "transition-colors duration-150 ease-out motion-reduce:transition-none",
          "hover:bg-zinc-950/[0.05] hover:text-zinc-950 dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-white",
          "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-500",
        )}
      >
        <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="size-3.5">
          <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
        </svg>
      </BaseToast.Close>
    </BaseToast.Root>
  );
}

function isToastType(type: string | undefined): type is ToastType {
  return type !== undefined && type in types;
}

function ToastIcon({ type }: { type: ToastType }) {
  const common = {
    "aria-hidden": true,
    viewBox: "0 0 16 16",
    fill: "none",
    className: cx(
      "mt-0.5 size-4 shrink-0",
      "transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
      "starting:opacity-0 motion-safe:starting:scale-90",
      types[type].tone,
    ),
  } as const;
  const stroke = { stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" } as const;
  switch (type) {
    case "success":
      return (
        <svg {...common}>
          <circle cx="8" cy="8" r="6.25" {...stroke} />
          <path d="m5.5 8.25 1.75 1.75 3.25-3.75" {...stroke} />
        </svg>
      );
    case "error":
      return (
        <svg {...common}>
          <circle cx="8" cy="8" r="6.25" {...stroke} />
          <path d="m6 6 4 4m0-4-4 4" {...stroke} />
        </svg>
      );
    case "warning":
      return (
        <svg {...common}>
          <circle cx="8" cy="8" r="6.25" {...stroke} />
          <path d="M8 5v3.25" {...stroke} />
          <circle cx="8" cy="10.75" r="0.8" fill="currentColor" />
        </svg>
      );
    case "info":
      return (
        <svg {...common}>
          <circle cx="8" cy="8" r="6.25" {...stroke} />
          <path d="M8 7.5v3.25" {...stroke} />
          <circle cx="8" cy="5.25" r="0.8" fill="currentColor" />
        </svg>
      );
    case "loading":
      return (
        <svg {...common} className={cx(common.className, "motion-safe:animate-spin")}>
          <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
          <path d="M14.25 8A6.25 6.25 0 0 0 8 1.75" {...stroke} />
        </svg>
      );
  }
}

/**
 * Adds and manages toasts. Call inside `<ToastProvider>`.
 *
 * ```tsx
 * const toast = useToast();
 * toast.success("Changes saved");
 * toast.add({ title: "Upload failed", description: "Try again.", type: "error", priority: "high" });
 * ```
 */
export function useToast() {
  const manager = BaseToast.useToastManager();
  return {
    /** Toasts currently held by the provider. */
    toasts: manager.toasts,
    /** Adds a toast and returns its id. */
    add: (options: ToastOptions) => manager.add(options),
    success: (title: ReactNode, options?: ShortcutOptions) => manager.add({ ...options, title, type: "success" }),
    error: (title: ReactNode, options?: ShortcutOptions) => manager.add({ ...options, title, type: "error" }),
    warning: (title: ReactNode, options?: ShortcutOptions) => manager.add({ ...options, title, type: "warning" }),
    info: (title: ReactNode, options?: ShortcutOptions) => manager.add({ ...options, title, type: "info" }),
    /** Closes one toast by id, or all toasts when called without an id. */
    close: manager.close,
    /** Updates a toast in place, for example to change its title or type. */
    update: manager.update,
    /** Shows a loading toast that turns into a success or error toast when the promise settles. */
    promise: manager.promise,
  };
}