Skip to content
UI

Messages and feedback

Which component tells the user what, from field errors to toasts, banners and confirmations.

Messages guide the user, confirm what happened and help them recover. They also interrupt, so every message has to earn its place.

Principles

  1. First, try to remove the need for the message. A disabled button with a tooltip, a sensible default or a clearer label often does more than an error after the fact.
  2. Interrupt only when the user must decide. Everything else is shown without stopping their work.
  3. Help the user recognize, understand and fix the problem. Say what happened and what to do, next to where it happened.
  4. Never rely on color alone. Pair status colors with text, and with an icon where space allows.

Choosing the component

Situation Component Interrupts
A field value is wrong or missing FieldError under the field, aria-invalid on the control (Field) No
An action finished toast.success() (Toaster) No
An action failed and the user can carry on toast.error() with what to do No
The page cannot work as expected (partial data, delayed sync, a missing integration) Alert or Info Banner, inline at the top of the affected area No
Context the user should know before acting Info Banner (role="note") No
An action is destructive or cannot be undone confirm() (Confirm Dialog) Yes
Leaving would lose unsaved changes confirm() with Discard as the action Yes
A list or card has nothing to show List Empty State No
A list or card failed to load List Error State No
Something is loading Skeleton when the layout is known, Spinner in a button or a small area No
Several items were processed See Several items Depends

Field validation

Validate when the user leaves a field and again on submit, not on every keystroke. Put the error under the field it belongs to, mark the control with aria-invalid, and move focus to the first invalid field on submit. The message says how to fix the value: “Enter a domain, such as acme.com.”, not “Invalid input.”

<Field data-invalid className="max-w-sm">
  <FieldLabel htmlFor="monitored-domain">Monitored domain</FieldLabel>
  <Input
    id="monitored-domain"
    defaultValue="https://acme"
    aria-invalid
    aria-describedby="monitored-domain-error"
  />
  <FieldError id="monitored-domain-error">
    Enter a domain without the protocol, such as acme.com.
  </FieldError>
</Field>

Toasts

A toast confirms the result of something the user just did, and goes away on its own.

  • One toast per action. A bulk action raises one toast with the count, not one per item.
  • Success toasts are short past-tense statements: “Rule created”.
  • Use the toast type that matches the result: toast.success(), toast.info(), toast.warning(), toast.error(). Each takes the colors and icon of its type; a plain toast() is neutral.
  • Do not put the only copy of an important error in a toast: it disappears. If the user must act on it, show it inline or in a dialog.
  • Offer at most one action in a toast, such as Undo or View, and give that toast at least 10 seconds (duration: 10000) so the user can read it and reach the button. Toasts pause while the pointer is over them.
<>
  <Button variant="outline" onClick={() => toast("Filter saved")}>
    Default
  </Button>
  <Button
    variant="outline"
    onClick={() => toast.success("Alert assigned to Marina Santos")}
  >
    Success
  </Button>
  <Button
    variant="outline"
    onClick={() => toast.info("Scan queued behind 2 other scans")}
  >
    Info
  </Button>
  <Button
    variant="outline"
    onClick={() => toast.warning("API key expires in 3 days")}
  >
    Warning
  </Button>
  <Button
    variant="outline"
    onClick={() => toast.error("Could not reach the SIEM connector")}
  >
    Error
  </Button>
</>

Long-running work

Scans, exports and imports can take minutes. Never hold the user in a modal or a page-wide spinner for them.

  1. Start the work and confirm it at once: toast("Export started").
  2. Show the progress where the result will appear: a “Running” status on the row, a Progress bar in the card.
  3. When it finishes, say so with an action that leads to the result: “Export ready” with Download, “Scan finished” with View results.
  4. When it fails, toast.error() with what to do, and keep the failed state visible where the result would have been, so the user finds it after the toast is gone.

Inline messages

Use an Alert for a message with a title and details, and an Info Banner for a single line. Place it at the top of the area it describes, not at the top of the page when only one card is affected. An inline message stays until the condition behind it is gone; do not make the user dismiss information that is still true.

Results include 2 of 3 tenants you can access.
All connectors reported in the last hour.
The CrowdStrike sync is delayed. Data may be 20 minutes old.
This report is a snapshot from October 1 and does not update.
Exports are disabled until the storage quota is raised.
<div className="grid w-full max-w-md gap-3">
  <InfoBanner>Results include 2 of 3 tenants you can access.</InfoBanner>
  <InfoBanner tone="success">
    All connectors reported in the last hour.
  </InfoBanner>
  <InfoBanner tone="warning">
    The CrowdStrike sync is delayed. Data may be 20 minutes old.
  </InfoBanner>
  <InfoBanner tone="neutral">
    This report is a snapshot from October 1 and does not update.
  </InfoBanner>
  <InfoBanner tone="destructive">
    Exports are disabled until the storage quota is raised.
  </InfoBanner>
</div>

Confirmations

Ask for confirmation only before an action that destroys data, cannot be undone or affects other people. Asking before routine actions teaches users to confirm without reading.

  • The title asks the question and names the object: “Delete asset vpn-01?”
  • The body says what else changes and whether it can be undone.
  • The confirm button repeats the verb (“Delete asset”) with the destructive variant; the other button is Cancel.
  • For the most destructive actions on a named resource, such as deleting a tenant, ask the user to type its name.

Not asked yet.

function ConfirmDialogDestructive() {
  const [result, setResult] = React.useState<boolean | null>(null)

  async function deleteReport() {
    const confirmed = await confirm({
      title: "Delete this report?",
      description:
        "Monthly exposure report, September 2026 will be deleted for everyone in the tenant. This cannot be undone.",
      confirmLabel: "Delete report",
      confirmVariant: "destructive",
    })
    setResult(confirmed)
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Button variant="destructive" onClick={deleteReport}>
        Delete report
      </Button>
      <p className="text-sm text-text-secondary">
        {result === null
          ? "Not asked yet."
          : `confirm() resolved to ${result}.`}
      </p>
    </div>
  )
}

Several items

When an action runs on many selected items, report the result once:

Result Feedback
Every item succeeded A success toast with the count: “12 alerts resolved”
The action applies to none of the items Disable the action before it runs; the tooltip says why
Some items cannot take the action Before running, a confirmation with the counts and the reason, offering to run on the rest
Some items failed while running A dialog that summarizes the result (“9 of 12 alerts resolved”), then lists each failed item with its reason, and a Close button

Keep the items selected afterwards, so the user can act on what failed.

Writing the message

The words matter as much as the component. Follow Writing: what happened, why when it helps, what to do; no blame; no raw error messages; no “successfully”.