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.
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 />;
}
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 />;
}
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 />;
}
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 />;
}
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 />;
}
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 />;
}
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>
);
}
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>
);
}
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 />;
}
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>
);
}
| 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.
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.
ToastOptions| Name | Type | Default | Description |
|---|---|---|---|
titlerequired | 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. Shown in: Show a toast, Tones, 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. Shown in: Show a toast, Tones, Undo and 11 more |
tone | ToastTone| "neutral" | — | 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 |
duration | number | the provider's duration, else 5000 | Display duration in ms; 0 = stays until closed by hand. Shown in: Keep it until it is closed, Inside a modal |
action | 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. Shown in: Undo, Save, and say how it went, Inside a modal |
| Name | Type | Default | Description |
|---|---|---|---|
onClose | (reason: ToastCloseReason) => void | — | Called once when the toast goes, with the reason. Shown in: Undo |
ToastAction| Name | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | A verb for what it does - "Undo", "Show". Shown in: Undo, Save, and say how it went, Inside a modal |
| Name | Type | Default | Description |
|---|---|---|---|
onClickrequired | () => void | — | Runs, and the toast goes with the reason action.Shown in: Undo, Save, and say how it went, Inside a modal |
ToastToneHow the message turns out; loading is a toast still at work.
type ToastTone = "neutral" | "success" | "warning" | "danger" | "loading";ToastCloseReasonWhy 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";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.loading toasts are not capped: the deck holds every one of them beside the timed ones.What umriss deliberately does not build, and why: ADR-0032.