Skip to content
txbrendan/ui

Toast

beta

A short note in the corner after something happened. An undo toast offers Undo for 8 seconds, then commits the change. Tones say how it went, and a promise toast follows work to its end.

Preview

undo

Design review

10:00 · Main office · Room 1

Team lunch

Sat 3 Oct · Studio

Quarterly review

Thu 8 Oct · 14:00 · Online

0 archived for good

promise

show

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/toast

Use it for

  • Place one <Toaster /> near the end of the root layout. Then call toast() from any client code, like an event handler.
  • Apply a change that can be undone at once, then call toast.undoable() with onUndo to put it back. onCommit runs when the 8 seconds end.
  • Use toast.promise() for work that lands somewhere else, like an import or a send. It shows a spinner, then success or danger.
  • Use toast() for news that nothing on screen shows, like a reminder that went out. Give it one action that goes to where the news lives.
  • Say what happened in the title, in a few words, like 'Archived Design review'. Add a description only when the title needs it.
  • Use tone 'danger' and timeout: 0 for a failure people must see, like a send that did not go, and give it a Retry action.

Avoid it for

  • Asking before an action that can be undone. Do it, and offer Undo in a toast.
  • An action that cannot be undone, like a payment or a removal. Use Alert Dialog.
  • A problem with one field or row. Say it under the thing with Notice.
  • A success toast for what the screen already shows, like a saved field. Show the result in place.
  • More than one Toaster on a page. Each one draws every toast, so each would show twice.
  • Rendering <Toast> to pop a toast. It is a still picture for docs. Call toast().

Props

  • Toaster

    Where toasts show, in the bottom right corner. Place one on the page.

    { limit?, timeout?, className?, aria-label? }

  • limit

    Toaster. How many toasts show at once. A newer one replaces the oldest.

    number · default 1

  • timeout

    How long a toast stays, in milliseconds: on Toaster for every toast, or in toast() for one. 0 keeps it until it is dismissed. An undo toast always stays 8 seconds.

    number · default 5000

  • className

    Toaster. Classes for the corner the toasts sit in, like 'right-auto left-4'. Later classes win.

    string

  • toast

    Shows a toast and returns its id. Pressing the action closes the toast, then runs onClick. Showing again with the same id replaces it in place.

    (title | { title, description?, tone?, action?: { label, onClick }, timeout?, priority?, id?, onClose? }) => string

  • tone

    In toast(). Colors and picks the icon. Neutral has none.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • priority

    In toast(). 'high' reads it out at once. Default 'high' for danger and 'low' for the rest.

    "low" | "high"

  • toast.promise

    Shows loading with a spinner, then success or danger. Each is a title or toast options, and success and error can be functions of the result. Returns the same promise, which still rejects.

    (work, { loading, success, error }) => Promise

  • toast.undoable

    Offers Undo for 8 seconds. Undo or ⌘Z runs onUndo once. Dismissing it, or the window ending, runs onCommit once. With no Toaster on the page, it commits at once.

    ({ title, description?, onUndo, onCommit?, undoLabel?, priority? }) => string

  • toast.close

    Closes one toast by id, or every toast with no id.

    (id?) => void

  • UNDO_WINDOW_MS

    How long an undo toast stays, in milliseconds.

    8000

  • Toast

    A still picture of a toast, for docs and galleries. It takes no clicks, and a server file can render it. action is the button's text.

    { title?, description?, tone?, loading?, action? }

  • toastVariants

    The toast's look. toast.tsx is a client file, so call it from a client file.

    ({ tone? }) => string

Accessibility

  • Toasts are read out by a live region. The default priority waits until the screen reader is quiet. 'high', the default for danger, reads at once.
  • Each tone has its own icon, so the tone does not rest on color alone. A loading toast sets aria-busy.
  • F6 moves focus to the toast, so keyboard users can reach its action and Dismiss.
  • The window holds while the pointer is on the toast, and while focus is in it.
  • ⌘Z or Ctrl+Z undoes the newest change while its window is open. It leaves text fields alone, so their own undo still works.
  • The Dismiss button is named for screen readers. It stays hidden from them until the toast is pointed at or focused.
  • The toast uses the popover colors. The theme tests hold its muted text and each tone's icon color to 4.5:1 on popovers.

Examples

Archive at once, and offer Undo

function EventRow({ id, title }: { id: string; title: string }) {
  const [hidden, setHidden] = useState(false);
  if (hidden) return null;
  return (
    <div className="flex items-center justify-between gap-3">
      {title}
      <Button
        emphasis="ghost"
        onClick={() => {
          setHidden(true);
          toast.undoable({
            title: `Archived ${title}`,
            onUndo: () => setHidden(false),
            onCommit: () => fetch(`/api/events/${id}/archive`, { method: "POST" }),
          });
        }}
      >
        Archive
      </Button>
    </div>
  );
}

Place one Toaster in the root layout

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  );
}

Follow an import to its end

toast.promise(importMembers(file), {
  loading: "Importing members…",
  success: (added) => ({
    title: "Import finished",
    description: `${added} members added to the workspace.`,
    action: { label: "Open", onClick: () => router.push("/members") },
  }),
  error: "Could not import",
});

A failure that stays until it is dismissed

toast({
  title: "Reminder not sent",
  description: "Check your connection, then try again.",
  tone: "danger",
  action: { label: "Retry", onClick: sendReminder },
  timeout: 0,
});