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
- 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.
- Interrupt only when the user must decide. Everything else is shown without stopping their work.
- Help the user recognize, understand and fix the problem. Say what happened and what to do, next to where it happened.
- 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 plaintoast()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.
- Start the work and confirm it at once:
toast("Export started"). - Show the progress where the result will appear: a “Running” status on the row, a Progress bar in the card.
- When it finishes, say so with an action that leads to the result: “Export ready” with Download, “Scan finished” with View results.
- 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.
<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
destructivevariant; 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”.