# Toast

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/toast/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

Short feedback in the corner of the window that interrupts nothing and goes away by itself (also called a snackbar or notification). Reach for it to confirm what has just happened: saved, exported, approved.

```ts
import { ToastProvider, useToast } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/toast/

[`useToast`](https://romanhaendler.github.io/umriss-ui/core/api/#useToast) needs a `ToastProvider` around the application, once, at the root.

Toasts stand above an open modal dialog and can be used there: they are seen, pressed and heard, and stay where they were while a dialog opens or closes.

`held` on the `ToastProvider` holds them back while a surface they must not cover is open: none is seen or heard, their time stands still, and they come back with the time they had left, so nothing is lost.

A toast stays five seconds by default. Its clock stops while the deck is open – under the pointer, with the focus inside it – and runs on with the time that was left.

The title says what has happened, in about one line: “Invoice not sent”. Why, and what to do next, go in the description.

Nothing that must be read goes in a toast: whoever looked elsewhere has missed it. `duration: 0` keeps one until it is closed – for the rare message whose loss costs something, since a corner full of standing toasts stops being read.

Several toasts stand as a deck: the newest in front, a count on it, two edges behind it. It opens under the pointer, on the count, with the focus inside it, or with Alt+T; Escape gives the focus back.

At most three that leave by themselves stand at once (`toast.limit` in the [UmrissProvider](https://romanhaendler.github.io/umriss-ui/core/umrissprovider/)); the oldest of them gives way to a new one, as if closed. A toast with `duration: 0` and a `loading` toast never give way and do not count.

## Examples

### Show a toast

Call `toast()` from [`useToast`](https://romanhaendler.github.io/umriss-ui/core/api/#useToast) with what has happened; a `ToastProvider` at the root of the application holds them.

```tsx
import { Button, useToast } from "@umriss-ui/core";

function Content() {
  const { toast } = useToast();

  return (
    <Button onClick={() => toast({ title: "Export finished", description: "The project list is ready as a CSV." })}>
      Show a toast
    </Button>
  );
}

/* `useToast` needs a `ToastProvider` above it; at the root of an application
   one provider serves every screen. */
export default function ShowAToast() {
  return <Content />;
}
```

### Tones

The `tone` says how it turned out; a symbol and the words say the same, so the colour is never alone.

```tsx
import { Button, Stack, useToast } from "@umriss-ui/core";

function Content() {
  const { toast } = useToast();

  return (
    <Stack direction="row" gap={3} wrap>
      <Button onClick={() => toast({ title: "Release 4.18 deployed", description: "Checkout and Billing run the new version.", tone: "success" })}>
        Success
      </Button>
      <Button onClick={() => toast({ title: "Connection lost", description: "Reconnecting to the metrics stream.", tone: "warning" })}>
        Warning
      </Button>
      <Button onClick={() => toast({ title: "Rollback failed", description: "Search still runs release 4.17.", tone: "danger" })}>
        Danger
      </Button>
    </Stack>
  );
}

/* `useToast` needs a `ToastProvider` above it; at the root of an application
   one provider serves every screen. */
export default function Tones() {
  return <Content />;
}
```

### Undo

One `action` reverses what just happened; `onClose` says whether the time ran out, the toast was closed or the action was taken, and the line under the list reports which.

```tsx
import { useState } from "react";
import { Button, Stack, Text, useToast } from "@umriss-ui/core";
import type { ToastCloseReason } from "@umriss-ui/core";

const BOOKINGS = ["Room 2.14 · Tue 10:00 · Design review", "Room 3.02 · Tue 14:00 · Quarterly planning", "Room 1.05 · Wed 09:30 · Onboarding"];

const OUTCOME: Record<ToastCloseReason, string> = {
  timeout: "The time ran out: the cancellation stands.",
  dismiss: "The toast was closed: the cancellation stands.",
  action: "Undone: the booking is back.",
};

function Content() {
  const { toast } = useToast();
  const [bookings, setBookings] = useState(BOOKINGS);
  const [outcome, setOutcome] = useState<string | null>(null);

  const cancel = (booking: string) => {
    setBookings((list) => list.filter((entry) => entry !== booking));
    setOutcome(null);
    toast({
      title: "Booking cancelled",
      description: booking,
      action: {
        label: "Undo",
        /* Back in its place in the week, whatever went meanwhile. */
        onClick: () => setBookings((list) => BOOKINGS.filter((entry) => entry === booking || list.includes(entry))),
      },
      onClose: (reason) => setOutcome(OUTCOME[reason]),
    });
  };

  return (
    <Stack gap={2} style={{ maxWidth: 480 }}>
      {bookings.map((booking) => (
        <Stack key={booking} direction="row" gap={3} align="center" justify="space-between">
          <Text size="sm">{booking}</Text>
          <Button size="sm" variant="secondary" onClick={() => cancel(booking)}>
            Cancel
          </Button>
        </Stack>
      ))}
      <Text size="xs" tone="muted">
        {outcome ?? `${bookings.length} of ${BOOKINGS.length} rooms booked`}
      </Text>
    </Stack>
  );
}

/* `useToast` needs a `ToastProvider` above it; at the root of an application
   one provider serves every screen. */
export default function Undo() {
  return <Content />;
}
```

### Keep it until it is closed

`duration: 0` keeps a toast until the reader closes it – for the rare message whose loss costs something.

```tsx
import { Button, useToast } from "@umriss-ui/core";

function Content() {
  const { toast } = useToast();

  return (
    <Button
      onClick={() =>
        toast({
          title: "Working offline",
          description: "Scans are kept on this device and sent when the connection is back.",
          tone: "warning",
          duration: 0,
        })
      }
    >
      Go offline
    </Button>
  );
}

/* `useToast` needs a `ToastProvider` above it; at the root of an application
   one provider serves every screen. */
export default function UntilClosed() {
  return <Content />;
}
```

### Save, and say how it went

A `loading` toast shows a spinner, has no close button and does not leave by itself; `update` turns it into its outcome in place, and only then does its time begin.

```tsx
import { useEffect, useRef, useState } from "react";
import { Button, Stack, Switch, useToast } from "@umriss-ui/core";

function Content() {
  const { toast, update } = useToast();
  const [fail, setFail] = useState(false);
  /* Read when the answer comes, so that "Try again" follows the switch. */
  const failRef = useRef(fail);
  useEffect(() => {
    failRef.current = fail;
  }, [fail]);

  const publish = () => {
    const id = toast({ title: "Publishing release notes…", tone: "loading" });
    setTimeout(() => {
      if (failRef.current) {
        update(id, {
          title: "Release notes not published",
          description: "Two sections still have open review comments.",
          tone: "danger",
          action: { label: "Try again", onClick: publish },
        });
      } else {
        update(id, {
          title: "Release notes published",
          description: "All 214 subscribers were notified.",
          tone: "success",
        });
      }
    }, 1500);
  };

  return (
    <Stack direction="row" gap={4} align="center" wrap>
      <Button onClick={publish}>Publish release notes</Button>
      <Switch label="Let it fail" checked={fail} onChange={(event) => setFail(event.target.checked)} />
    </Stack>
  );
}

/* `useToast` needs a `ToastProvider` above it; at the root of an application
   one provider serves every screen. */
export default function SaveWithLoading() {
  return <Content />;
}
```

### Confirm an action in a list

The row changes where the reader looks; the toast confirms it in passing, so a second approval never waits on the first. Approve all three: the toasts stand as a deck with a count on the front one, and open under the pointer, on the count or with Alt+T.

```tsx
import { useState } from "react";
import { Badge, Button, Stack, Text, useToast } from "@umriss-ui/core";

const INVOICES = [
  { id: "INV-26-0318", supplier: "Brandlow Office Supply", amount: "€ 1,965.84" },
  { id: "INV-26-0317", supplier: "Kettering & Shaw Events", amount: "€ 12,733.00" },
  { id: "INV-26-0316", supplier: "Northgate Cloud Services", amount: "€ 4,284.00" },
];

function Content() {
  const { toast } = useToast();
  const [approved, setApproved] = useState<string[]>([]);

  return (
    <Stack gap={2} style={{ maxWidth: 560 }}>
      {INVOICES.map((invoice) => {
        const done = approved.includes(invoice.id);
        return (
          <Stack key={invoice.id} direction="row" gap={3} align="center" justify="space-between">
            <Stack gap={1}>
              <Text size="sm" weight="medium">
                {invoice.supplier}
              </Text>
              <Text size="xs" tone="muted" mono>
                {invoice.id} · {invoice.amount}
              </Text>
            </Stack>
            {done ? (
              <Badge tone="success">Approved</Badge>
            ) : (
              <Button
                size="sm"
                onClick={() => {
                  setApproved((list) => [...list, invoice.id]);
                  toast({ title: `${invoice.id} approved`, description: "It goes to Finance for payment.", tone: "success" });
                }}
              >
                Approve
              </Button>
            )}
          </Stack>
        );
      })}
    </Stack>
  );
}

/* `useToast` needs a `ToastProvider` above it; at the root of an application
   one provider serves every screen. */
export default function AfterAnAction() {
  return <Content />;
}
```

### Where they stand

`toast.position` in the `UmrissProvider` puts every toast of the application at one edge and one place along it; the deck grows away from that edge. On a phone they take the window's width, and only the edge counts. How far they stand from the edge is `--u-toast-inset-top` and `--u-toast-inset-bottom` on `:root` - below a fixed header, above a toolbar at the bottom; how far from the window's sides, `--u-toast-inset-start` and `--u-toast-inset-end` - beside a navigation rail, at the page's gutter.

```tsx
import { useMemo, useState } from "react";
import { Button, Stack, ToastProvider, UmrissProvider, useToast } from "@umriss-ui/core";
import type { ToastConfig, ToastPosition } from "@umriss-ui/core";

const POSITIONS: { position: ToastPosition; label: string }[] = [
  { position: "top-start", label: "Top start" },
  { position: "top-center", label: "Top center" },
  { position: "top-end", label: "Top end" },
  { position: "bottom-start", label: "Bottom start" },
  { position: "bottom-center", label: "Bottom center" },
  { position: "bottom-end", label: "Bottom end" },
];

function Buttons({ onPick }: { onPick: (position: ToastPosition) => void }) {
  const { toast } = useToast();
  return (
    <Stack direction="row" gap={2} wrap>
      {POSITIONS.map(({ position, label }) => (
        <Button
          key={position}
          size="sm"
          variant="secondary"
          onClick={() => {
            onPick(position);
            toast({ title: "Report exported", description: `Standing ${label.toLowerCase()}.`, tone: "success" });
          }}
        >
          {label}
        </Button>
      ))}
    </Stack>
  );
}

export default function WhereTheyStand() {
  const [position, setPosition] = useState<ToastPosition>("top-center");
  /* A new object is a new setting: kept until the position changes. */
  const config = useMemo<ToastConfig>(() => ({ position }), [position]);
  return (
    <UmrissProvider toast={config}>
      <ToastProvider>
        <Buttons onPick={setPosition} />
      </ToastProvider>
    </UmrissProvider>
  );
}
```

### How many stand at once

`toast.limit` in the `UmrissProvider` caps the toasts that leave by themselves: here two stand, and each new one pushes out the oldest of them as if it had been closed. Scan four parcels in a row and only the last two remain.

```tsx
import { Button, ToastProvider, UmrissProvider, useToast, type ToastConfig } from "@umriss-ui/core";

type ToastFn = ReturnType<typeof useToast>["toast"];

const PARCELS = ["FP-2081", "FP-2082", "FP-2083", "FP-2084"];

function scan(toast: ToastFn) {
  PARCELS.forEach((parcel) => toast({ title: `Parcel ${parcel} scanned`, description: "Loaded onto tour T-03.", tone: "success" }));
}

function ScanButton() {
  const { toast } = useToast();
  return (
    <Button variant="primary" onClick={() => scan(toast)}>
      Scan four parcels
    </Button>
  );
}

/* Kept outside the component: a new object on every render would be a new
   setting on every render. */
const TOAST: ToastConfig = { limit: 2 };

export default function HowManyStand() {
  return (
    <UmrissProvider toast={TOAST}>
      <ToastProvider>
        <ScanButton />
      </ToastProvider>
    </UmrissProvider>
  );
}
```

### Inside a modal

A toast stands above an open modal dialog and can be pressed there: the dialog's "Save" fails, and the toast carries "Retry" next to the form it belongs to.

```tsx
import { useState } from "react";
import { Button, FormField, Input, Modal, ModalBody, ModalFooter, ModalHeader, Select, Text, Tooltip, useToast } from "@umriss-ui/core";

function Content() {
  const { toast } = useToast();
  const [open, setOpen] = useState(false);

  const save = () =>
    toast({
      title: "Profile not saved",
      description: "The server did not answer.",
      tone: "danger",
      /* A failure stays until it is read. */
      duration: 0,
      action: { label: "Retry", onClick: save },
    });

  return (
    <>
      <Button onClick={() => setOpen(true)}>Edit the profile</Button>
      <Modal open={open} onClose={() => setOpen(false)} size="sm">
        <ModalHeader title="Edit the profile" description="Changes are saved to the account." />
        <ModalBody>
          <FormField label="Display name">
            <Input defaultValue="Maya Lindgren" />
          </FormField>
          <FormField label="Language">
            <Select defaultValue="en">
              <option value="en">English</option>
              <option value="de">Deutsch</option>
              <option value="fr">Français</option>
              <option value="es">Español</option>
            </Select>
          </FormField>
          <Tooltip content="Shows the profile as colleagues see it.">
            <Button size="sm" variant="secondary">
              Preview
            </Button>
          </Tooltip>
          <Text size="xs" tone="muted">
            Nothing is kept until it is saved.
          </Text>
        </ModalBody>
        <ModalFooter>
          <Button onClick={() => setOpen(false)}>Cancel</Button>
          <Button variant="primary" onClick={save}>
            Save
          </Button>
        </ModalFooter>
      </Modal>
    </>
  );
}

export default function InsideAModal() {
  return <Content />;
}
```

### Hold the toasts

`held` on the `ToastProvider` keeps every toast out of sight and hearing while a surface is open that they must not cover and there is no room beside it. Their time stands still, a new one waits, and when the hold ends they come back with the time they had left: nothing is lost, as it would be by dismissing them.

```tsx
import { useState } from "react";
import { Button, Stack, Switch, ToastProvider, useToast } from "@umriss-ui/core";

function SendButton() {
  const { toast } = useToast();
  return (
    <Button onClick={() => toast({ title: "Route T-03 planned", description: "12 stops, back by 16:40.", tone: "success" })}>
      Send a toast
    </Button>
  );
}

export default function HoldTheToasts() {
  const [held, setHeld] = useState(false);
  return (
    <ToastProvider held={held}>
      <Stack direction="row" gap={4} align="center">
        <Switch label="Hold the toasts" checked={held} onChange={(event) => setHeld(event.target.checked)} />
        <SendButton />
      </Stack>
    </ToastProvider>
  );
}
```

## When to use something else

- The reader must see it, or it stays true until something is done → Alert
- The reader must decide before going on → ConfirmDialog

## Keyboard

| Key | Action |
|---|---|
| `Alt + T` | While a toast stands and is not held back, moves the focus into the messages and opens the deck. |
| `Tab / Shift + Tab` | Moves through the toasts' actions and close crosses; a keyboard focus inside opens the deck and holds every toast's clock. |
| `Enter / Space` | On an action, runs it and closes its toast; on a cross, closes the toast. |
| `Escape` | Closes the deck; after Alt + T, the focus goes back to where it was. |

The keys of [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here.

## Accessibility

Two live regions stand in the corner before the first message, since a region that arrives with its text is often not read: a neutral, success or loading toast goes into a polite `status`, a warning or danger one into an `alert` that interrupts, the table [Alert](https://romanhaendler.github.io/umriss-ui/core/alert/) follows too. While a toast stands, the corner is a region named "Messages (Alt+T)".

The count on a deck of several is out of the tab order, since a focus on it would open the deck and take the count away; the keyboard opens the deck by Alt + T or by moving into it. A loading toast has no cross and does not leave by itself: `update` turns it into its outcome.

With reduced motion toasts appear, stack and leave without movement.

Inside a modal dialog the live regions move with the toasts, so a message is heard there as well as outside.

## API

### `ToastOptions`

| Prop | Type | Default | Description |
|---|---|---|---|
| `title` *required* | `string` | — | What has happened – short, about one line (some 45 characters), in the perfect tense and not a request: "Invoice not sent". Why, and what to do next, belong in the description. A longer title wraps; it is never cut.<br>Shown in: [Show a toast](https://romanhaendler.github.io/umriss-ui/core/toast/#show-a-toast), [Tones](https://romanhaendler.github.io/umriss-ui/core/toast/#tones), [Undo](https://romanhaendler.github.io/umriss-ui/core/toast/#undo) and 13 more |
| `description` | `string` | — | One more sentence where the title alone is too little. Nothing the reader MUST read: the toast goes away by itself.<br>Shown in: [Show a toast](https://romanhaendler.github.io/umriss-ui/core/toast/#show-a-toast), [Tones](https://romanhaendler.github.io/umriss-ui/core/toast/#tones), [Undo](https://romanhaendler.github.io/umriss-ui/core/toast/#undo) and 11 more |
| `tone` | [`ToastTone`](https://romanhaendler.github.io/umriss-ui/core/toast/#type-ToastTone)<br>`"neutral" \| "success" \| "warning" \| "danger" \| "loading"` | — | How the message turns out. The tone is never the only information – the glyph beside it and the text say the same thing once more. `loading` shows a spinner, has no close button and does not leave by itself: turn it into its outcome with `update`.<br>Shown in: [Tones](https://romanhaendler.github.io/umriss-ui/core/toast/#tones), [Keep it until it is closed](https://romanhaendler.github.io/umriss-ui/core/toast/#until-closed), [Save, and say how it went](https://romanhaendler.github.io/umriss-ui/core/toast/#save-with-loading) and 10 more |
| `duration` | `number` | the provider's `duration`, else `5000` | Display duration in ms; 0 = stays until closed by hand.<br>Shown in: [Keep it until it is closed](https://romanhaendler.github.io/umriss-ui/core/toast/#until-closed), [Inside a modal](https://romanhaendler.github.io/umriss-ui/core/toast/#inside-a-modal) |
| `action` | [`ToastAction`](https://romanhaendler.github.io/umriss-ui/core/toast/#type-ToastAction) | — | One action - "Undo". Never the only way to do what it offers: the toast leaves by itself, and not everybody reaches it in time. More than one action is a dialog.<br>Shown in: [Undo](https://romanhaendler.github.io/umriss-ui/core/toast/#undo), [Save, and say how it went](https://romanhaendler.github.io/umriss-ui/core/toast/#save-with-loading), [Inside a modal](https://romanhaendler.github.io/umriss-ui/core/toast/#inside-a-modal) |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onClose` | `(reason: `[`ToastCloseReason`](https://romanhaendler.github.io/umriss-ui/core/toast/#type-ToastCloseReason)`) => void` | — | Called once when the toast goes, with the reason.<br>Shown in: [Undo](https://romanhaendler.github.io/umriss-ui/core/toast/#undo) |

### `ToastAction`

| Prop | Type | Default | Description |
|---|---|---|---|
| `label` *required* | `string` | — | A verb for what it does - "Undo", "Show".<br>Shown in: [Undo](https://romanhaendler.github.io/umriss-ui/core/toast/#undo), [Save, and say how it went](https://romanhaendler.github.io/umriss-ui/core/toast/#save-with-loading), [Inside a modal](https://romanhaendler.github.io/umriss-ui/core/toast/#inside-a-modal) |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onClick` *required* | `() => void` | — | Runs, and the toast goes with the reason `action`.<br>Shown in: [Undo](https://romanhaendler.github.io/umriss-ui/core/toast/#undo), [Save, and say how it went](https://romanhaendler.github.io/umriss-ui/core/toast/#save-with-loading), [Inside a modal](https://romanhaendler.github.io/umriss-ui/core/toast/#inside-a-modal) |

### Types on this page

#### `ToastTone`

How the message turns out; `loading` is a toast still at work.

```ts
type ToastTone = "neutral" | "success" | "warning" | "danger" | "loading";
```

#### `ToastCloseReason`

Why a toast went: its time ran out, it was closed (or gave way to a newer
one), or its action was taken.

```ts
type ToastCloseReason = "timeout" | "dismiss" | "action";
```

## Known limits

- One action at most, and never the only way to what it offers: the toast leaves by itself. What needs a decision needs an [Alert](https://romanhaendler.github.io/umriss-ui/core/alert/) or a [ConfirmDialog](https://romanhaendler.github.io/umriss-ui/core/confirmdialog/).
- One position for the whole application, set in the [UmrissProvider](https://romanhaendler.github.io/umriss-ui/core/umrissprovider/); none per toast.
- One `ToastProvider` per application, at its root. A second one in the same document draws a second deck at the same place and the two lie on each other; in development it is reported on the console.
- No queue: a toast over the limit pushes the oldest timed one out rather than waiting. Lasting and `loading` toasts are not capped: the deck holds every one of them beside the timed ones.
- Without the Popover API the deck moves with a dialog that transforms itself: inside a dialog the toasts stand in the top layer only where the browser has it, and elsewhere they are a fixed box in the dialog.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
