# Toast

> An animated toast stack for React and shadcn, with an action button such as Undo.

Toasts rise 12px on the smooth spring and stack behind the newest, peeking 10px and 5% smaller; hover fans them out and pauses every timer. Promise toasts morph from spinner to check in place.

- Tier: Free (MIT)
- Interaction: Loading
- Page: https://eased.dev/components/toast
- Installs: @base-ui/react, motion
- Registry: https://eased.dev/r/toast.json

## Install

```bash
npx shadcn add https://eased.dev/r/toast.json
```

A stacked toast manager on Base UI Toast: a `Toaster` that mounts the stack, a `useToast()` hook, and helpers for toasting from outside React.

## When to use

- Confirming something that just happened away from where the person is looking: "Deployed to production", "Invite sent".
- A reversible action with an Undo window: "Moved 3 issues to Done" with an Undo button.
- Work that finishes later: a promise toast shows a spinner, then morphs in place into the result.
- Errors that are not tied to one field, such as a background job or a failed sync.

## When not to use

- A destructive action in one list or table, where the undo should sit by the content: use [Undo Pill](/components/undo-pill).
- Form errors: show them inline beside the field (R72).
- Anything the person must answer before going on: use a [Dialog](/components/dialog).
- Persistent status ("You're offline"): use an inline [Alert](/components/alert); toasts time out.

## Installation

Mount one `Toaster` near the root of your app. Components inside it can call `useToast()`.

```tsx app/layout.tsx
import { Toaster } from "@/components/eased/toast/toast";

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

## Usage

```tsx deploy-button.tsx
import { Button } from "@/components/eased/button/button";
import { useToast } from "@/components/eased/toast/toast";

export function DeployButton({ deploy }: { deploy: () => Promise<{ seconds: number }> }) {
  const toast = useToast();
  return (
    <Button
      variant="primary"
      onClick={() =>
        toast.promise(deploy(), {
          loading: { title: "Deploying northwind-web…", description: "Building main · 7d3e1b2" },
          success: (r) => ({ title: "Deployed to production", description: `northwind.app · built in ${r.seconds}s` }),
          error: (e) => ({ title: "Deploy failed", description: String(e) }),
        })
      }
    >
      Deploy
    </Button>
  );
}
```

## Examples

### Undo

`action` adds one button (an Eased `Button`, small, secondary). The toast closes after the click unless `onClick` returns `false`.

```tsx
toast.add({
  title: "Moved 3 issues to Done",
  description: "NW-412, NW-418 and NW-421",
  action: { label: "Undo", onClick: () => restore(ids) },
});
```

### Success and error

```tsx
toast.success("Invite sent", { description: "devon@northwind.io can now view “Q3 launch plan”." });
toast.error("Build failed again", { description: "DATABASE_URL is missing in Preview." });
```

### A global manager

To toast from outside React (a fetch wrapper, a store), create a manager once and bind the helpers to it.

```tsx lib/toast.ts
import { createToastApi, createToastManager } from "@/components/eased/toast/toast";

export const toastManager = createToastManager();
export const toast = createToastApi(toastManager);
```

```tsx
<Toaster toastManager={toastManager} />;

toast.success("Saved");
```

### Inside a container

Pass `container` to portal the stack into an element instead of `document.body`. The stack is then `absolute` inside it, so give the element `position: relative`. The demo does this so its toasts land in its own frame.

## API reference

### Toaster

| Prop | Type | Default | Description |
|---|---|---|---|
| `position` | `"bottom-right" \| "bottom-center" \| "top-center" \| "top-right"` | `"bottom-right"` | Where the stack sits. Under 640px it is always full-width at the bottom, above the safe area. |
| `limit` | `number` | `3` | Toasts shown at once. Older ones fade out (`data-limited`) and come back as slots free. |
| `timeout` | `number` | `5000` | Default auto-dismiss in ms; `0` keeps toasts until closed. Loading toasts never time out. |
| `toastManager` | `ToastManager` | | A manager from `createToastManager()`, for toasting outside React. |
| `container` | `HTMLElement \| null` | `document.body` | Portal target. When set, the stack is `absolute` inside it. |
| `swipeDirection` | `ToastSwipeDirection \| ToastSwipeDirection[]` | per position | Swipes that dismiss: down + right at the bottom, up + right top right, up top centre. |
| `closeLabel` | `string` | `"Dismiss"` | Accessible name of each close button. |
| `className` | `string` | `""` | Appended to the viewport's classes. |
| `children` | `ReactNode` | | Your app; `useToast()` works inside it. |

### useToast / createToastApi

`useToast()` (inside a `Toaster`) and `createToastApi(manager)` (anywhere) return the same methods. They are thin wrappers over Base UI's manager.

| Method | Description |
|---|---|
| `add(options)` | Shows a toast and returns its id. An existing `id` updates that toast in place. |
| `success(title, options?)` | A success toast with a check. |
| `error(title, options?)` | An error toast with an alert glyph, announced urgently (`priority: "high"`) unless you pass `priority`. |
| `promise(promise, { loading, success, error })` | One toast that follows the promise and morphs in place. Each state is a title or options; `success` and `error` may be functions of the result. Returns the promise. |
| `update(id, options)` | Changes a toast in place; its timer restarts. |
| `close(id?)` | Closes one toast, or all of them. |

### ToastOptions

| Option | Type | Default | Description |
|---|---|---|---|
| `title` | `ReactNode` | | One short line. Promise toasts keep it to one line so it can swap. |
| `description` | `ReactNode` | | Supporting text; wraps. |
| `type` | `"default" \| "success" \| "error" \| "loading"` | `"default"` | Sets the icon. `"default"` has none, so the title starts at the edge. |
| `action` | `{ label: string; onClick: () => void \| false }` | | One action button, e.g. Undo. |
| `timeout` | `number` | the Toaster's | Per-toast auto-dismiss in ms. |
| `priority` | `"low" \| "high"` | `"low"` | `"high"` is announced at once (an `alert`). |
| `id` | `string` | generated | Reuse an id to update a toast instead of adding one. |
| `data.icon` | `ReactNode` | | Replaces the status icon. |
| `onClose`, `onRemove`, `actionProps` | | | Base UI's options pass through. |

`createToastManager` re-exports Base UI's. Toasts carry `data-type`, so you can restyle a type from outside.

## Keyboard

| Key | Action |
|---|---|
| `F6` | Moves focus into the toast region and expands the stack. |
| `Shift+Tab` (on the region) | Returns focus to where it was before `F6`. |
| `Tab` / `Shift+Tab` | Moves through the toasts and their action and close buttons. |
| `Enter` / `Space` | Runs the focused action, or closes the toast from its close button. |
| `Esc` | Closes the focused toast. |

## Accessibility

- The viewport is a `region` landmark named "Notifications" and a polite live region, so new toasts and their text changes (a promise resolving) are announced. `priority: "high"` toasts (errors by default) are announced through an `alert` instead.
- Each toast is a non-modal `dialog` labelled by its title and described by its description.
- Hover, keyboard focus inside the stack and a hidden window all pause every timer, so nothing disappears while someone reads or reaches for Undo.
- The close button appears on hover with a fine pointer and on keyboard focus, and always shows on touch screens; it keeps its space when hidden, so nothing shifts. Touch users can also swipe a toast away.
- Toasts past `limit` are `inert` until they return.

## Motion

A toast rises 12px at 0.97 into place on the smooth spring (`--eased-spring-smooth`, 450ms) with a 200ms opacity tween; it leaves half the distance and half the scale in 150ms on the standard curve, never overshooting. Swiped away, it carries on in the swipe's direction from where the finger let go. Behind the newest, toasts peek 10px and shrink 5% each, scaled from the stack's anchored edge, with their content faded out; hovering or focusing fans the stack out 8px apart on the same spring, and every move is interruptible. No height is animated: each toast keeps its natural height, and a taller toast behind slides its card layer to the front toast's height with a clip that sits behind the front. A promise toast morphs in place: the spinner cross-fades and scales (0.6 → 1, snappy) into a check that draws its stroke in, or an alert glyph, while the title swaps with Text Swap. Reduced motion keeps only opacity: positions change at once and fades take 100ms.

## Notes for AI

Mount one `Toaster` at the app root (or use a global manager with `createToastApi`), then call `useToast()` where things happen. Use `promise` for anything async rather than adding a loading toast and closing it, so the toast morphs instead of replacing itself; use `action` for Undo. Keep titles to one short line and put detail in `description`.

Do not wrap toasts in your own animation, timers or `aria-live` region: Base UI pauses timers, owns swipe and announces toasts, and the stack motion is already there. Do not use a toast for form validation or for anything that must be acknowledged; use inline errors or a Dialog.

## Related

- [Undo Pill](/components/undo-pill): an undo bar attached to one list, with a draining ring.
- [Alert](/components/alert): inline, persistent status that does not time out.
- [Dialog](/components/dialog): when the person has to answer.

## Example

`components/eased/toast/toast-demo.tsx`

```tsx
"use client";

import { useState } from "react";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { rowGrid, rowGridRow } from "../lib/layout";
import { TextSwap } from "../text-swap/text-swap";
import { Toaster, useToast } from "./toast";

type Deploy = { id: string; message: string; meta: string; age: string; status: "ready" | "failed" };

const DEPLOYS: Deploy[] = [
  { id: "a91c03e", message: "Fix invoice rounding on EU plans", meta: "Preview · a91c03e · Build failed", age: "18m", status: "failed" },
  { id: "4f2c9e1", message: "Add usage-based billing page", meta: "Production · 4f2c9e1 · main", age: "2h", status: "ready" },
  { id: "c07b5d2", message: "Cache plan lookups at the edge", meta: "Production · c07b5d2 · main", age: "1d", status: "ready" },
];

/*
 * The demo mounts its own Toaster and portals the stack into the frame (`container`), so the
 * toasts land in the bottom-right of this "app window", as they would in a real app, instead of
 * the corner of the docs page, far from the buttons that fired them.
 */
export function ToastDemo() {
  const [frame, setFrame] = useState<HTMLDivElement | null>(null);
  return (
    <Toaster container={frame}>
      <Project layer={setFrame} />
    </Toaster>
  );
}

/** Each Deploy ships the next commit, so repeat presses read as new work. */
const COMMITS = [
  { sha: "7d3e1b2", message: "Show seat count on the plan card" },
  { sha: "e5a4f90", message: "Retry failed webhooks with backoff" },
  { sha: "2b8d6c1", message: "Add VAT ID field to checkout" },
  { sha: "9f1e7a4", message: "Speed up the invoices list query" },
];

function Project({ layer }: { layer: (el: HTMLDivElement | null) => void }) {
  const toast = useToast();
  const [deploys, setDeploys] = useState(DEPLOYS);
  const [deploying, setDeploying] = useState(false);
  const [done, setDone] = useState(false);
  const [count, setCount] = useState(0);

  const deploy = () => {
    setDeploying(true);
    const { sha, message } = COMMITS[count % COMMITS.length];
    setCount((n) => n + 1);
    const build = new Promise<number>((resolve) => window.setTimeout(() => resolve(38), 1800));
    void toast
      .promise(build, {
        loading: { title: "Deploying northwind-web…", description: `Building main · ${sha}` },
        success: (seconds) => ({ title: "Deployed to production", description: `northwind.app · built in ${seconds}s` }),
        error: "Deploy failed",
      })
      .then(() =>
        setDeploys((list): Deploy[] => [{ id: sha, message, meta: `Production · ${sha} · main`, age: "now", status: "ready" as const }, ...list.filter((d) => d.id !== sha)].slice(0, 4)),
      )
      .finally(() => setDeploying(false));
  };

  const moveToDone = () => {
    setDone(true);
    toast.add({
      title: "Moved 3 issues to Done",
      description: "NW-412, NW-418 and NW-421",
      // An icon keeps this toast's title on the same x as the status toasts stacked with it (R101).
      data: { icon: <DoneIcon /> },
      action: { label: "Undo", onClick: () => setDone(false) },
    });
  };

  const retry = () =>
    toast.error("Build failed again", {
      description: "DATABASE_URL is missing in Preview. Add it under Settings → Environment.",
    });

  return (
    <DemoFrame
      title="northwind-web"
      actions={
        <Button variant="primary" size="sm" loading={deploying} onClick={deploy}>
          Deploy
        </Button>
      }
    >
      <div className="flex min-h-0 flex-1 flex-col gap-6 px-(--pad) py-4">
        <section className="flex flex-col gap-2">
          <h3 className="text-ui-sm font-medium text-text-muted">Linked issues</h3>
          <div className="flex items-center justify-between gap-3">
            <div className="flex min-w-0 flex-col">
              <span className="truncate text-ui text-text">NW-412, NW-418, NW-421</span>
              <span className="text-ui-sm text-text-muted">
                3 issues · <TextSwap>{done ? "Done" : "In review"}</TextSwap>
              </span>
            </div>
            <Button size="sm" disabled={done} onClick={moveToDone}>
              Move to Done
            </Button>
          </div>
        </section>

        <section className="flex min-h-0 flex-col gap-1">
          <h3 className="text-ui-sm font-medium text-text-muted">Deployments</h3>
          <ul className={`${rowGrid} gap-x-3 [--col:4rem]`}>
            {deploys.map((d) => (
              <li key={d.id} className={`${rowGridRow} h-12`}>
                <span aria-hidden className={`size-2 rounded-pill ${d.status === "ready" ? "bg-success" : "bg-danger"}`} />
                <span className="flex min-w-0 flex-col">
                  <span className="truncate text-ui-sm text-text">{d.message}</span>
                  <span className="truncate text-ui-xs text-text-muted">{d.meta}</span>
                </span>
                <span className="flex justify-end">
                  {d.status === "failed" ? (
                    <Button variant="ghost" size="sm" edge="end" onClick={retry}>
                      Retry
                    </Button>
                  ) : (
                    <span className="text-ui-xs text-text-muted tabular-nums">{d.age}</span>
                  )}
                </span>
              </li>
            ))}
          </ul>
        </section>
      </div>
      {/* The toast stack's portal target: the frame is the positioned ancestor. */}
      <div ref={layer} />
    </DemoFrame>
  );
}

function DoneIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-4 text-text-muted" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
      <circle cx="8" cy="8" r="6.25" />
      <path d="M5.25 8.25 7 10l3.75-4" />
    </svg>
  );
}
```
