Toast
SaveBrief 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 toastPre-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-runUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 timeout | number | 5000 | Default time in ms before a toast closes. 0 keeps toasts until dismissed. Timers pause on hover and focus. |
| ToastProvider limit | number | 3 | Most toasts shown at once; older ones are hidden until newer ones close. |
| ToastProvider toastManager | ToastManager | — | A manager from createToastManager(), for adding toasts outside React components. |
| ToastProvider closeLabel | string | "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 / description | ReactNode | — | 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 actionProps | ComponentProps<"button"> | — | Renders an action button, for example { children: "Undo", onClick }. |
| ToastOptions timeout | number | — | 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.tsxShowHide
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,
};
}