Skip to content

Toast

A message that announces itself and goes away. Reach for it to confirm something that has already happened, or to report something that failed: the note saved, the upload finished, the server refused. It is for a notice the user does not have to answer.

When the user does have to answer, that is a dialog, because a toast takes no focus and cannot hold a decision. When the message belongs to one control rather than to the screen, put it beside that control: invalid on a field says more than a notice in the corner. When the label only needs to appear on a hover, that is a tooltip.

tsx
/**
 * Two notices, and the two things a toast can be.
 *
 * `Saved` is the ordinary one: `status`, so an assistive technology
 * reads it when it next has a turn, and a three second timer that
 * closes it without anyone doing anything.
 *
 * `Could not save` is `tone="error"`: `alert`, which interrupts, and
 * `duration={0}`, so it stays up until it is dismissed. Anything the
 * user has to act on gets a duration of 0, because a message that
 * leaves on its own is a message that can be missed.
 *
 * Both are controlled. A `Toast` never opens itself: the cell it is
 * given is what opens it, `onClose` is how the timer and the dismiss
 * button ask for the cell to be written back, and a screen that
 * declines to write it back keeps the toast up.
 *
 * Each `Toast` leaves a zero-size invisible placeholder where it is
 * declared, and its box is drawn in the overlay layer instead, pinned
 * 24 pixels off the bottom left corner of the viewport. Two open at
 * once would be drawn on top of each other, so this raises one at a
 * time.
 */
export function Notices(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  const saved = internalState(false);
  const failed = internalState(false);

  const raise = (which: 'saved' | 'failed') => () => {
    saved.value = which === 'saved';
    failed.value = which === 'failed';
  };

  return (
    <column gap={14} padding={20} width={percent(100)} height={percent(100)} y="center">
      <text text="Toasts are drawn over the app, in the bottom left corner." fontSize={13} color="text" />
      <row gap={8}>
        <button
          label="Save the note"
          onClick={raise('saved')}
          padding={8}
          borderRadius={6}
          backgroundColor="controlBackground"
          cursor="pointer"
          modifiers={[HOVER_CONTROL]}>
          <text text="Save the note" fontSize={13} />
        </button>
        <button
          label="Save it badly"
          onClick={raise('failed')}
          padding={8}
          borderRadius={6}
          backgroundColor="controlBackground"
          cursor="pointer"
          modifiers={[HOVER_CONTROL]}>
          <text text="Save it badly" fontSize={13} />
        </button>
      </row>

      <Toast open={saved} message="Note saved" duration={3000} onClose={() => (saved.value = false)} />
      <Toast
        open={failed}
        message="Could not save the note"
        tone="error"
        duration={0}
        onClose={() => (failed.value = false)}
      />
    </column>
  );
}

Press either button. The first notice closes itself after three seconds; the second is an error, has no duration, and stays until the ✕ is pressed.

Props

PropTypeDefaultWhat it does
openbooleannoneWhether the notice is up. The application owns this.
onClose() => voidnoneCalled when the timer expires or the ✕ is pressed. Write open back here.
messagestring''The text, and the accessible name of the box.
tone'info' | 'error''info'info waits its turn; error interrupts. See the semantics below.
durationnumber4000Milliseconds before it dismisses itself. 0 keeps it up.
dismissiblebooleantrueShows the ✕ that closes it.

Every prop takes a plain value or an Observable of one. Toast takes none of the shared layout props, because it is not placed in your layout: see below.

There is no uncontrolled form. A toast has nothing to own: it is up because the application put it up.

tone and duration are read when the toast opens, and so is the accessible name. The text drawn follows the message cell, so a message that changes while a toast is up changes what is on screen but not what was announced; raise a new toast instead of editing an open one. The spec beside the example measures both halves of that.

Where it is drawn

A Toast returns a zero-size, invisible placeholder where you declare it, and its box is drawn in the overlay layer the runtime mounts above the app root, pinned 24 pixels off the bottom left corner of the viewport. Nothing in your layout moves when one opens, and nothing in your layout decides where it goes.

Declare it inside the tree whose appearance it should match. The placeholder is what the overlay layer re-provides the theme, text style and content colour from, which is what keeps a toast raised from a dark panel dark.

There is no queue and no stack. Two toasts open at once are two entries pinned to the same corner, drawn on top of each other, so raise one at a time: the example holds two Toast elements and one cell each, and opening either closes the other.

Keyboard

Toast binds no keys and takes no focus. A notification that stole the keyboard from what the user was doing would be a bug, and Escape is not bound, so a toast is not something a reader has to dismiss before carrying on.

KeyWhat it does
EscapeNot bound. It reaches whatever else is listening
TabReaches the ✕ inside the toast, after the controls on the screen

The ✕ is an ordinary button, so it is in the tab order like any other, and because the overlay layer sits above the app root it comes after the screen's own controls rather than in the middle of them. With dismissible={false} there is no button and no tab stop, which is only safe alongside a duration that closes it.

Semantics

WhatValue
Rolestatus when tone is info, alert when it is error
Namemessage, read once when the toast opens
StatesNone
FocusNever taken. A toast is read through its role, not visited
DismissThe ✕ is a button named Dismiss, present while dismissible

The two roles are the whole reason tone exists. An alert interrupts whatever an assistive technology was reading; a status waits its turn. Choose by whether the user needs to know now, not by whether the message is bad news.

error also draws the border and the text in the danger token, so the tone is carried visually and semantically from the same prop. No other colour is named: the box is surface with a border border.

What has been checked

The roles, the name, the auto-dismiss and its timer, duration: 0, the dismiss button, the tab order and the corner it is pinned to are asserted in the spec beside the example, driving the real runtime with a fake canvas. No screen reader has been sat in front of the alert and status records, which is a different question from whether they are emitted.

Next

Tooltip is the label a reader asks for by hovering, and the library overview covers the contract every control here shares.