Toast

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.

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

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); 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 with what has happened; a ToastProvider at the root of the application holds them.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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 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 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

NameTypeDefaultDescription
titlerequiredstring—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.

Shown in: Show a toast, Tones, Undo and 13 more

descriptionstring—One more sentence where the title alone is too little. Nothing the reader MUST read: the toast goes away by itself.

Shown in: Show a toast, Tones, Undo and 11 more

toneToastTone
| "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.

Shown in: Tones, Keep it until it is closed, Save, and say how it went and 10 more

durationnumberthe provider's duration, else 5000Display duration in ms; 0 = stays until closed by hand.

Shown in: Keep it until it is closed, Inside a modal

actionToastAction—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.

Shown in: Undo, Save, and say how it went, Inside a modal

Events

NameTypeDefaultDescription
onClose(reason: ToastCloseReason) => void—Called once when the toast goes, with the reason.

Shown in: Undo

ToastAction

NameTypeDefaultDescription
labelrequiredstring—A verb for what it does - "Undo", "Show".

Shown in: Undo, Save, and say how it went, Inside a modal

Events

NameTypeDefaultDescription
onClickrequired() => void—Runs, and the toast goes with the reason action.

Shown in: Undo, Save, and say how it went, Inside a modal

Types on this page

ToastTone

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

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.

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

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index