Skip to content
cennge-ui
GitHubv0.3.1, changelog

Toast

Short notifications at the edge of the screen, sent with toast() from anywhere in your code.

cennge-ui@0.3.0

Release from main

Draft
  • Charts, in cennge-ui/charts
  • Theme switching without a flash
  • Kbd draws its key symbols at the size of the key
  1. Publish: the toast shows a spinner while the request runs, then turns into the result in place.
  2. Point at a toast and its timer stops until the pointer leaves. Swipe it to the right, or press its close button, to dismiss it.
  3. Discard, then Undo: the action runs and closes the toast. F8 takes keyboard focus to the notifications.
Live: the toasts appear at the bottom of the window.

Field marks

Import
import { Toaster, toast } from "cennge-ui";
Size
2.6 KB gzip for this import, its dependencies aside
Pattern
A labelled region of toasts; each one's text is read out as it appears
Keyboard
F8Tab Esc
State
A store outside React: toast() works without a hook or a context
Timer
5 s by default; stops under the pointer and while focus is inside
On screen
Four at most by default (max); older toasts wait for a free place
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: toast

When to use#

A toast confirms something the person has done, or reports something that finished out of sight: a link copied, a deploy that went live, a build that failed. It comes and goes without taking focus, so use it for news that can be missed.

When the person must answer before going on, use an AlertDialog. When the message belongs to a part of the page and should stay there, use an Alert.

Similar#

  • Alert sits in the flow of the page, next to what it is about, and stays there; a toast floats over the page and leaves by itself.
  • AlertDialog stops everything until it is answered; a toast never takes focus.

Mount the Toaster once#

Put one <Toaster /> near the root of the app, after the content. This site has one in its root layout, which is where the toasts on this page come from.

import { Toaster } from "cennge-ui";

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

placement puts the toasts in one of six places, bottom-right by default, and they are swiped away towards the nearest edge: right, left, or up and down for the centred ones. On a phone they take the full width, with the same margin on both sides, wherever they are placed. On React 17 toasts do not show yet: Radix Toast 1.3.1 drops its own state updates there (radix-ui/primitives#4228).

A message, with or without a description#

toast("Link copied") shows a title alone. A second argument adds options, here a description under the title. Each call returns the toast's id.

basic.tsx
import { Button, toast } from "cennge-ui";

export default function ToastBasic() {
  return (
    <div className="flex flex-wrap gap-3">
      <Button variant="flat" onClick={() => toast("Link copied")}>
        Copy link
      </Button>
      <Button color="primary" onClick={() => toast.success("Deployed", { description: "spredo-crypto-screener is live" })}>
        Deploy
      </Button>
    </div>
  );
}

Colours#

toast.success, toast.info, toast.warning and toast.danger set the toast's color and draw its icon; toast.error is another name for toast.danger. A plain toast() has no icon. icon replaces the drawn one, and icon: false leaves it out.

colors.tsx
import { Button, toast } from "cennge-ui";

export default function ToastColors() {
  return (
    <div className="flex flex-wrap gap-3">
      <Button variant="flat" onClick={() => toast("Draft saved")}>
        Default
      </Button>
      <Button
        variant="flat"
        color="success"
        onClick={() => toast.success("Published", { description: "cennge-ui@0.3.1 is on npm" })}
      >
        Success
      </Button>
      <Button
        variant="flat"
        color="info"
        onClick={() => toast.info("New version", { description: "Reload to get cennge-ui 0.3.1" })}
      >
        Info
      </Button>
      <Button
        variant="flat"
        color="warning"
        onClick={() => toast.warning("Token expires soon", { description: "The npm token used by CI" })}
      >
        Warning
      </Button>
      <Button
        variant="flat"
        color="danger"
        onClick={() => toast.danger("Build failed", { description: "ai-niche-scout · main" })}
      >
        Danger
      </Button>
    </div>
  );
}

An action#

action adds one button under the text. Pressing it runs onClick and closes the toast, which makes it the place for an undo: close the issue now, and reopen it if that was a slip.

Cache misses after a deployOpen
action.tsx
import { useState } from "react";
import { Badge, Button, toast } from "cennge-ui";

export default function CloseIssue() {
  const [isOpen, setOpen] = useState(true);

  function close() {
    setOpen(false);
    toast("Issue #12 closed", {
      description: "payload-cache",
      action: { label: "Reopen", onClick: () => setOpen(true) },
    });
  }

  return (
    <div className="flex flex-wrap items-center gap-3">
      <span className="text-sm">Cache misses after a deploy</span>
      <Badge color={isOpen ? "success" : "secondary"}>{isOpen ? "Open" : "Closed"}</Badge>
      <Button size="sm" variant="flat" isDisabled={!isOpen} onClick={close}>
        Close issue
      </Button>
    </div>
  );
}

From a promise#

toast.promise shows the loading message with a spinner, and the toast does not close by itself while the promise runs. Then it turns into a success or danger toast in place. success and error can be functions of the result or the error, and the call returns the promise it was given.

promise.tsx
import { Button, toast } from "cennge-ui";

// Stands in for the real request: resolves with the version, or fails without a token.
function publish(hasToken: boolean) {
  return new Promise<string>((resolve, reject) =>
    setTimeout(() => (hasToken ? resolve("0.3.1") : reject(new Error("401: no npm token"))), 1500),
  );
}

export default function PublishPromise() {
  function run(hasToken: boolean) {
    toast.promise(publish(hasToken), {
      loading: "Publishing cennge-ui…",
      success: (version) => `Published cennge-ui@${version}`,
      error: (error) => `Publishing failed: ${error instanceof Error ? error.message : "unknown error"}`,
    });
  }

  return (
    <div className="flex flex-wrap gap-3">
      <Button color="primary" onClick={() => run(true)}>
        Publish
      </Button>
      <Button variant="flat" onClick={() => run(false)}>
        Publish without a token
      </Button>
    </div>
  );
}

Update a toast in place#

Call toast() again with the same id and that toast changes instead of a new one stacking up; the new options are merged into the old ones. isLoading shows a spinner and keeps the toast open until it is set back to false. toast.dismiss(id) closes a toast from code, and toast.dismiss() closes them all.

update.tsx
import { useState } from "react";
import { Button, toast } from "cennge-ui";

const files = ["home-dark.png", "home-light.png", "combobox.png", "map-new-york.png"];
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function UploadScreenshots() {
  const [isUploading, setUploading] = useState(false);

  async function upload() {
    setUploading(true);
    const id = toast("Uploading screenshots", { description: `0 of ${files.length}`, isLoading: true });
    for (const [index, file] of files.entries()) {
      await wait(700); // stands in for the upload of `file`
      toast("Uploading screenshots", { id, description: `${index + 1} of ${files.length}: ${file}` });
    }
    toast.success("Screenshots uploaded", { id, description: `${files.length} files`, isLoading: false });
    setUploading(false);
  }

  return (
    <Button variant="flat" isLoading={isUploading} onClick={upload}>
      Upload {files.length} screenshots
    </Button>
  );
}

Accessibility#

The Toaster is a region named “Notifications (F8)”. F8 moves focus into it, Tab goes through the toasts and their buttons, and Escape closes the toast that has focus. Each toast's text is read out as soon as it appears, without moving focus. The timer stops while the pointer is over the toasts or focus is inside them, so there is time to read a toast and reach its action. The close button is a 28-pixel target.

Props#

Toaster#

PropTypeDefault
placement
"top-right""top-left""bottom-right""bottom-left""top-center""bottom-center"
"bottom-right"
duration
numberDefault auto-close delay, ms.
5000
max
numberMost toasts on screen at once; older ones wait.
4
label
stringScreen-reader name of the region. Press F8 to jump to it.
"Notifications"
className
string
–

toast() options#

The second argument of toast() and its colour variants, or the first instead of a title.

  • title: the first line, in bold. toast("Saved") sets it.
  • description: a second line, in muted text.
  • color: default, primary, secondary, success, warning, danger or info. The last four draw an icon.
  • duration: milliseconds before the toast closes; Infinity keeps it open. Without it, the Toaster's duration applies.
  • action: { label, onClick }, a button that runs onClick and closes the toast.
  • icon: replaces the colour's icon; false hides it.
  • isLoading: a spinner in place of the icon, and no closing by time.
  • id: an existing toast's id updates that toast instead of adding one.
  • onClose: runs when the toast closes by time, by a swipe, by Escape, by its close button or by its action; not after toast.dismiss().