Skip to content

Toasts ​

A stack of toasts in the bottom right corner, over everything: what an action is doing while it runs, how it went when it is over, and a deletion that can still be taken back. The toasts live in a store you create once and call from anywhere.

tsx
import { Toasts, createToastStore } from '@basmilius/react-ui';

export const toasts = createToastStore();

// once, near the root
<Toasts store={toasts} />;
tsx
import { Button, Toasts, createToastStore } from '@basmilius/react-ui';

const toasts = createToastStore();

const wait = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms));

async function push(): Promise<void> {
    const id = toasts.getState().show({ kind: 'progress', title: 'Pushing to origin' });
    await wait(1500);
    toasts.getState().update(id, { kind: 'success', title: 'Pushed to origin', description: '3 commits' });
}

function fail(): void {
    toasts.getState().show({ kind: 'error', title: 'Could not fetch', description: 'The remote did not answer within 30 seconds.' });
}

function remove(): void {
    const id = toasts.getState().show({
        kind: 'deleted',
        title: 'Moved "notes.md" to the trash',
        action: { label: 'Undo', run: () => toasts.getState().dismiss(id) }
    });
}

export default function ToastsDemo() {
    return (
        <div className="flex flex-wrap items-center gap-2">
            <Button variant="secondary" onClick={() => void push()}>
                Push
            </Button>
            <Button variant="secondary" onClick={fail}>
                Fetch
            </Button>
            <Button variant="secondary" onClick={remove}>
                Delete a file
            </Button>
            <Toasts store={toasts} />
        </div>
    );
}

Kinds and lifetimes ​

KindIconGoes away
progressA spinnerWhen you update or dismiss it.
successA checkAfter 4 seconds (SUCCESS_MS), unless persist is set.
errorAn alertWhen the person dismisses it. A failure waits to be read.
deletedA trash canAfter 8 seconds (UNDO_MS).

A toast with an action that goes by itself, such as the undo of a deletion, shows a ring in place of its close button that counts down to the moment it goes. The ring starts as far along as the timer already is, so both end together. With reduced motion the ring stays full. A toast that goes by itself keeps its close button hidden until the pointer or the keyboard is on it.

One id from start to end ​

A long action keeps one toast from progress to outcome, so a person never watches two cards for one thing. show answers the toast's id; update moves that toast on:

ts
const id = toasts.getState().show({ kind: 'progress', title: 'Pushing to origin' });
try {
    await push();
    toasts.getState().update(id, { kind: 'success', title: 'Pushed to origin' });
} catch (error) {
    toasts.getState().update(id, { kind: 'error', title: 'Could not push', description: messageOf(error) });
}

show with the id of a toast that is already up replaces it. onClose runs once a toast is gone, whether it ran out or was dismissed.

The store ​

createToastStore<T>() returns a zustand store hook (ToastStoreHook<T>). Its state (ToastStore<T>) is:

Member
toastsT[], the toasts that are up, oldest first.
show(toast)Puts one up, or replaces the one with the same id. Answers its id. Takes a ToastInput<T>.
update(id, patch)Changes a toast that is up and restarts its timer. Takes a ToastPatch<T>.
dismiss(id)Takes a toast down.

A Toast is { id, title, kind, description?, action?, persist?, onClose?, deadline? }. The store sets deadline (a ToastDeadline of start and end in epoch milliseconds) from the timer that takes the toast away. elapsedOf(deadline, now) says how far into its lifetime a toast is. A ToastAction is { label, run, shortcut? }; the shortcut is only printed, so bind the key yourself. ToastKind is the union of the four kinds.

An app that carries more on a toast extends Toast and hands its type to the store, then draws the extra fields with footer:

tsx
interface AppToast extends Toast {
    output?: string;
}

const toasts = createToastStore<AppToast>();

<Toasts store={toasts} footer={(toast) => toast.output && <CopyOutputButton text={toast.output} />} />;

Props ​

PropType
storeToastStoreHook<T>Required.
footer(toast: T) => ReactNodeDrawn under the description.
classNamestring
refRef<HTMLDivElement>

The stack is a polite live region, so a screen reader announces a new toast without interrupting. ToastsProps, Toast, ToastAction, ToastDeadline, ToastInput, ToastKind, ToastPatch, ToastStore and ToastStoreHook are exported types.