Toast
A toast is a short message that slides in at the edge of the screen and leaves by itself. It confirms what just happened without getting in the way. The ten examples below cover the common cases.
"use client";
import { useRef } from "react";
import { Moon, Palette } from "lucide-react";
import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { ToastDescription, ToastTitle, useToast, type ToastPosition } from "@/components/ui/toast";
const places: { position: ToastPosition; label: string }[] = [
{ position: "top-left", label: "Top left" },
{ position: "top-center", label: "Top center" },
{ position: "top-right", label: "Top right" },
{ position: "bottom-left", label: "Bottom left" },
{ position: "bottom-center", label: "Bottom center" },
{ position: "bottom-right", label: "Bottom right" },
];
export default function ToastDemo() {
const toast = useToast();
const saves = useRef(0);
const place = useRef(0);
return (
<>
{/* 1. Success */}
<Button variant="secondary" onClick={() => toast.add({ type: "success", title: "Successfully toasted!" })}>
Success
</Button>
{/* 2. Error */}
<Button variant="secondary" onClick={() => toast.add({ type: "error", title: "This didn't work." })}>
Error
</Button>
{/* 3. Promise: the toast follows the promise. Every second save fails. */}
<Button
variant="secondary"
onClick={() => {
saves.current += 1;
const fails = saves.current % 2 === 0;
const save = new Promise<void>((resolve, reject) => setTimeout(fails ? reject : resolve, 2000));
toast.promise(save, {
loading: "Saving...",
success: "Settings saved!",
error: "Could not save settings.",
}).catch(() => {});
}}
>
Promise
</Button>
{/* 4. Multi line */}
<Button
variant="secondary"
onClick={() =>
toast.add({
title: "Multi-line message",
description:
"A longer description wraps onto several lines instead of being cut off, and the toast grows to fit it.",
})
}
>
Multi line
</Button>
{/* 5. Emoji: any element, or text like an emoji, can be the icon */}
<Button variant="secondary" onClick={() => toast.add({ title: "Good job!", data: { icon: "👏" } })}>
Emoji
</Button>
{/* 6. Dark: the page background with a bright border */}
<Button
variant="secondary"
onClick={() => toast.add({ title: "Dark mode", data: { tone: "dark", icon: <Moon /> } })}
>
Dark mode
</Button>
{/* 7. JSX content: the title and description accept elements */}
<Button
variant="secondary"
onClick={() =>
toast.add({
title: "Custom JSX",
description: (
<>
Links, <Kbd>keys</Kbd> and <em>emphasis</em> work here. <Link href="/docs">Read the docs</Link>.
</>
),
})
}
>
JSX content
</Button>
{/* 8. Themed: a soft accent wash with an accent icon */}
<Button
variant="secondary"
onClick={() =>
toast.add({
title: "Themed toast",
description: "Colors come from the theme tokens.",
data: { tone: "themed", icon: <Palette className="text-accent" /> },
})
}
>
Themed
</Button>
{/* 9. Custom position: each press uses the next place */}
<Button
variant="secondary"
onClick={() => {
const { position, label } = places[place.current % places.length];
place.current += 1;
toast.add({ title: label, description: "Toasts in the same place stack.", data: { position } });
}}
>
Custom position
</Button>
{/* 10. Tailwind: your own layout, built from classes */}
<Button
variant="secondary"
onClick={() =>
toast.add({
timeout: 8000,
data: {
custom: (
<div className="flex items-start gap-3">
<Avatar size="sm">
<AvatarImage src="/assets/images/claude-ai-icon.webp" alt="" />
<AvatarFallback>C</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-1">
<ToastTitle>Claude</ToastTitle>
<ToastDescription>Hey 👋 The review is done. Want to see the notes?</ToastDescription>
</div>
</div>
),
},
})
}
>
Tailwind
</Button>
</>
);
}The code
The component is a single file, read from the repository when the site is built. Press Show code to see all of it.
"use client";
import type { ComponentProps, ReactNode } from "react";
import { CircleAlert, CircleCheck, X } from "lucide-react";
import { Toast as BaseToast } from "@base-ui/react/toast";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cx } from "@/lib/cx";
import { popupShape } from "@/lib/styles";
import type { WithClass } from "@/lib/types";
// Toasts are messages that appear for a few seconds and go away by themselves. Mount one
// ToastProvider near the top of the app (this site does it in the root layout) and call
// `useToast().add({ title, description })` from anywhere inside it.
//
// Options for add():
// - `type`: "success" or "error" add an icon and tint the border; "loading" shows a spinner
// and never goes away on its own. Leave it out for a plain toast.
// - `timeout`: how long it stays, in milliseconds (5000 by default, 0 keeps it until closed).
// - `actionProps`: adds a button, such as Undo.
// - `id`: lets you close or update that toast later with `toast.close(id)`.
// - `data`: the extras below.
export type ToastPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right";
export type ToastData = {
// Replaces the icon of the type, or adds one to a plain toast. An emoji or any element.
icon?: ReactNode;
// Where the toast appears. Toasts in the same place stack. Bottom right by default.
position?: ToastPosition;
// "dark" puts the toast on the page background with a bright border. "themed" gives it a
// soft accent wash.
tone?: "dark" | "themed";
// Replaces the whole body. Build it from ToastTitle and ToastDescription so the toast is
// still announced and named.
custom?: ReactNode;
};
// useToast().promise(promise, { loading, success, error }) shows a spinner toast and turns it
// into a success or an error toast when the promise settles.
export function useToast() {
return BaseToast.useToastManager<ToastData>();
}
const typeIcons: Record<string, ReactNode> = {
success: <CircleCheck className="text-accent" />,
error: <CircleAlert className="text-danger" />,
loading: <Spinner aria-hidden className="size-5" />,
};
const tones = {
default: "border-border-strong bg-surface",
dark: "border-text bg-bg",
themed: "border-accent/30 bg-accent/10",
} as const;
// Each place is a column in a corner (or the top and bottom middle). The newest toast sits
// closest to the edge. Top places start below the sticky header.
const places: Record<ToastPosition, string> = {
"top-left": "top-20 left-4 flex-col sm:left-6",
"top-center": "top-20 left-1/2 -translate-x-1/2 flex-col",
"top-right": "top-20 right-4 flex-col sm:right-6",
"bottom-left": "bottom-4 left-4 flex-col-reverse sm:bottom-6 sm:left-6",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse sm:bottom-6",
"bottom-right": "bottom-4 right-4 flex-col-reverse sm:right-6 sm:bottom-6",
};
export function ToastTitle({ className, ...props }: WithClass<ComponentProps<typeof BaseToast.Title>>) {
return <BaseToast.Title className={cx("text-base leading-snug font-semibold text-text", className)} {...props} />;
}
export function ToastDescription({ className, ...props }: WithClass<ComponentProps<typeof BaseToast.Description>>) {
return <BaseToast.Description className={cx("text-sm text-text/70", className)} {...props} />;
}
function ToastItem({ toast, top }: { toast: BaseToast.Root.ToastObject<ToastData>; top: boolean }) {
const { type, data } = toast;
const icon = data?.icon ?? (type ? typeIcons[type] : undefined);
const slide = top
? "data-[starting-style]:-translate-y-2 data-[ending-style]:-translate-y-2"
: "data-[starting-style]:translate-y-2 data-[ending-style]:translate-y-2";
return (
<BaseToast.Root
toast={toast}
className={cx(
popupShape,
tones[data?.tone ?? "default"],
"pointer-events-auto relative flex items-start gap-3 p-4 pr-11 select-none",
"transition-[opacity,translate] duration-200 ease-out data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 motion-reduce:transition-none",
slide,
"data-[type=success]:border-accent/40 data-[type=error]:border-danger/40",
)}
>
{data?.custom ?? (
<>
{icon && (
<span
aria-hidden
className="mt-px flex size-5 shrink-0 items-center justify-center text-lg leading-none [&_svg]:size-5"
>
{icon}
</span>
)}
<div className="flex min-w-0 flex-1 flex-col gap-1 break-words">
<ToastTitle />
<ToastDescription />
<BaseToast.Action render={<Button variant="secondary" className="mt-2 self-start" />} />
</div>
</>
)}
<BaseToast.Close
aria-label="Dismiss"
className={cx(
"absolute top-[15px] right-3 flex size-6 cursor-pointer items-center justify-center rounded-sm text-text-secondary transition-colors after:absolute after:-inset-1.5 hover:text-text",
"outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-text",
)}
>
<X aria-hidden className="size-4" />
</BaseToast.Close>
</BaseToast.Root>
);
}
function ToastList() {
const { toasts } = BaseToast.useToastManager<ToastData>();
return (Object.keys(places) as ToastPosition[]).map((position) => {
const group = toasts.filter((toast) => (toast.data?.position ?? "bottom-right") === position);
if (group.length === 0) return null;
return (
<div
key={position}
className={cx("pointer-events-none absolute flex w-[calc(100vw-2rem)] max-w-sm gap-2", places[position])}
>
{group.map((toast) => (
<ToastItem key={toast.id} toast={toast} top={position.startsWith("top")} />
))}
</div>
);
});
}
export function ToastProvider({ children }: { children: ReactNode }) {
return (
<BaseToast.Provider>
{children}
<BaseToast.Portal>
<BaseToast.Viewport className="pointer-events-none fixed inset-0 z-60 outline-hidden">
<ToastList />
</BaseToast.Viewport>
</BaseToast.Portal>
</BaseToast.Provider>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title | ReactNode | — | In add(). The main line of the toast. Text, or any element. |
description | ReactNode | — | In add(). A second, dimmer line with the details. Text, or any element. |
type | "success" | "error" | "loading" | — | In add(). Success and error add an icon and tint the border green or red. Loading shows a spinner and stays until it is updated or closed. Leave it out for a plain toast. |
timeout | number | 5000 | In add(). Milliseconds before it goes away by itself. 0 keeps it until it is closed. |
id | string | — | In add(). A name for the toast, so you can close or update it later with close(id). |
actionProps | object | — | In add(). Adds a button, such as Undo, to the toast. Give it children for the label and onClick for what it does. |
data.icon | ReactNode | — | In add(). Replaces the icon of the type, or gives a plain toast one. An emoji or any icon element. |
data.position | ToastPosition | "bottom-right" | In add(). Where the toast appears: top-left, top-center, top-right, bottom-left, bottom-center or bottom-right. Toasts in the same place stack. |
data.tone | "dark" | "themed" | — | In add(). Dark puts the toast on the page background with a bright border. Themed gives it a soft accent wash. |
data.custom | ReactNode | — | In add(). Replaces the whole body with your own layout. Build it with ToastTitle and ToastDescription so it is still announced. |
promise | function | — | On useToast(). Call promise(thePromise, { loading, success, error }) to show a loading toast that turns into a success or an error toast when the promise settles. Each message is a string. |
Toasts are created with useToast().add({ ... }) from any client component inside the ToastProvider. This site mounts one in the root layout. Every other option goes to the Base UI Toast underneath.
The thinking
Some feedback is too small for a dialog and too important to lose. A toast says it, then gets out of the way.
- Built on Base UI: Base UI keeps the list of toasts, runs each one's timer, pauses the timers while a toast is hovered or focused, and announces new toasts to screen readers.
- One provider: ToastProvider goes once, high in the app. After that any client component can call add() without passing anything down.
- Ten examples: Success, error, promise, multi-line, emoji, dark, JSX content, themed, custom position and a fully custom layout. Each button in the demo is one of them, and its code is in the file below.
- Promise: For work that takes a moment, like saving. The toast shows a spinner while it waits and then becomes the result in place, so there is one toast and not two.
- Toast or Alert: A toast is temporary and about something that just happened. An Alert stays in the page for a state that is still true.
Interface design
- Compact: An icon, then the text. Icons come from the icon library, tinted green for success and red for error, and a spinner for loading.
- Surface: The shared overlay surface. Success and error toasts tint the border, and the words and icon still carry the meaning.
- Places: Six places: the corners and the top and bottom middle. Bottom right is the default. Toasts in a place stack, with the newest nearest the edge, and the top places start below the sticky header.
- Phone: Toasts stretch across the screen with a 16px margin on each side.
- Above a dialog: Toasts sit above dialogs, so a message is still visible while one is open.
- Close button: A small icon button in the corner, for people who want it gone now.
Interaction
- Appear and leave: Each toast fades and slides in from the edge it is closest to in 200ms, stays for five seconds, then fades out. With reduced motion turned on it appears and leaves at once.
- Pause: While the pointer is over a toast, or it has focus, its timer is paused, so there is time to read it or reach its button.
- Loading: A loading toast has no timer. When the promise settles it changes to success or error and starts the usual five seconds.
- Dismiss: The close button removes it at once.
- Action: An action button such as Undo runs its function. Use it for what the person might regret.
Empathy
- Announced: New toasts are announced by screen readers without moving focus away from what the person is doing.
- Icons are decoration: The icon repeats what the words say, so it is hidden from screen readers. The title carries the meaning.
- Custom layouts: If you replace the body, put the text in ToastTitle and ToastDescription. A toast made of loose elements has no name for a screen reader.
- Enough time: Five seconds is for short messages. Pausing on hover and focus gives slower readers more time, and a longer timeout suits longer text.
- Don't hold what matters: A toast can be missed. Errors that block progress should also show in the page, next to the thing that failed.