Skip to content

Feedback and undo

Every mutation answers back: inline, in a toast, or in the place that changed.

The problem#

When someone acts and nothing visibly answers, they click again, refresh, or assume it failed. When everything answers with a toast, the toasts stop being read.

Feedback goes wrong in both directions. A remittance that sends silently gets sent twice. A status change confirmed by a toast in the far corner, when the row it changed is right under the cursor, pulls the eye away from the work. A failed save reported only in a toast disappears in five seconds, and the person never learns which field to fix.

The solution in Canon#

Every mutation answers back, in the place the person is already looking. The changed thing updates first; a toast is for what the screen can't show; a banner is for a condition that persists.

Every mutation answers back

Saving, sending, approving, deleting: each one changes something visible or raises a toast. Nothing finishes silently.

Answer where they're looking

If the change shows in place (a row, a status, a count), that is the answer. Toast only what the screen can't show.

Reversible means Undo

A toast for a reversible change carries Undo. A toast for a failure says what failed and offers Try again when it can.

Decision guide#

Where the answer goes, by what happened.

Where feedback goes
What happenedAnswer withExample
A value changed where the person is lookingThe value itself, plus updated metaAssignee reads Jordan Lee, Assigned just now
Something left the screen or happened elsewhereSuccess toastRemittance sent
A reversible removal or changeToast with UndoRemoved INV-20431 from the run
A field's value was rejectedField error under the controlJordan Lee already approves runs over $25,000.
An action failed for a reason that isn't a fieldError toast with Try againCouldn't send the remittance
A condition persists until someone actsAlert at the top of the page or sectionBank connection lost
A read failedThe component's own error stateSee Loading, empty and error
Background work started and will finish laterInfo toast now, success toast when doneSeptember export is ready

Inline: the changed thing updates#

The best feedback is the change itself. Reassign the invoice or match it to a receipt and the card answers in place, with meta that says who did it and when.

INV-20417

Northwind Freight

Updated Sep 25 by Tomás Ferreira

$18,240.00
Match
Receipt missing
import { Button } from "@oration/canon/components/button";import { PendingButton } from "@oration/canon/components/pending-button";import { SelectField } from "@oration/canon/components/select-field";import { StatusLabel } from "@oration/canon/components/status-dot";import * as React from "react";export function InlineFeedback() {    const id = React.useId();    const [assignee, setAssignee] = React.useState("priya");    const [matched, setMatched] = React.useState(false);    const [pending, setPending] = React.useState(false);    const [updated, setUpdated] = React.useState(        "Updated Sep 25 by Tomás Ferreira",    );    const people: Record<string, string> = {        priya: "Priya Raman",        jordan: "Jordan Lee",        aisha: "Aisha Bello",    };    return (        <div className="flex w-full max-w-lg flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex items-start justify-between gap-3">                <div className="min-w-0">                    <p className="font-mono text-xs text-muted-foreground">                        INV-20417                    </p>                    <p className="text-sm font-semibold">Northwind Freight</p>                    <p                        aria-live="polite"                        className="text-xs text-muted-foreground"                    >                        {updated}                    </p>                </div>                <span className="text-sm font-semibold tabular-nums">                    $18,240.00                </span>            </div>            <dl className="flex flex-col gap-2 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13">                <div className="flex items-center justify-between gap-3">                    <dt className="text-muted-foreground">                        <label htmlFor={`${id}-assignee`}>Assignee</label>                    </dt>                    <dd>                        <SelectField                            id={`${id}-assignee`}                            size="sm"                            value={assignee}                            onValueChange={(value) => {                                setAssignee(value);                                setUpdated(                                    `Assigned to ${people[value] ?? value} just now`,                                );                            }}                            options={Object.entries(people).map(                                ([value, label]) => ({                                    value,                                    label,                                }),                            )}                            className="w-40 bg-background"                        />                    </dd>                </div>                <div className="flex min-h-7 items-center justify-between gap-3">                    <dt className="text-muted-foreground">Match</dt>                    <dd>                        {matched ? (                            <StatusLabel tone="success">                                Matched to PO 4471                            </StatusLabel>                        ) : (                            <StatusLabel tone="warning">                                Receipt missing                            </StatusLabel>                        )}                    </dd>                </div>            </dl>            <div className="flex justify-end gap-2">                {matched ? (                    <Button                        variant="ghost"                        size="sm"                        onClick={() => {                            setMatched(false);                            setUpdated("Updated Sep 25 by Tomás Ferreira");                        }}                    >                        Reset example                    </Button>                ) : (                    <PendingButton                        variant="outline"                        size="sm"                        pending={pending}                        onClick={() => {                            setPending(true);                            window.setTimeout(() => {                                setPending(false);                                setMatched(true);                                setUpdated("Matched just now by Maya Okafor");                            }, 700);                        }}                    >                        Match to receipt                    </PendingButton>                )}            </div>        </div>    );}
  • Update the value, its status label and any counts or totals in the same frame.
  • Refresh the meta line (Matched just now by Maya Okafor) so the change is attributed.
  • If the request takes long enough to notice, the button that started it shows pending and holds its width. See Pending button.

Toasts#

Toasts answer actions whose result isn't on screen. Call toast.add({ title, description?, type?, actionProps? }) from @oration/canon/components/toast; one Toaster in the root layout shows them.

Success, Undo, info and error

import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function ToastKinds() {    return (        <div className="flex flex-wrap justify-center gap-2">            <Button                variant="outline"                onClick={() =>                    toast.add({                        type: "success",                        title: "Remittance sent",                        description:                            "Northwind Freight gets the advice for PAY-1182 at ap@northwindfreight.com.",                    })                }            >                Success            </Button>            <Button                variant="outline"                onClick={() =>                    toast.add({                        title: "Removed INV-20431 from the run",                        description:                            "RUN-0932 is now 56 invoices, $236,996.12.",                        actionProps: {                            children: "Undo",                            onClick: () =>                                toast.add({                                    title: "INV-20431 is back in RUN-0932",                                }),                        },                    })                }            >                With Undo            </Button>            <Button                variant="outline"                onClick={() =>                    toast.add({                        type: "info",                        title: "Thursday's run locks at noon",                        description:                            "Invoices approved after 12:00 PM PT go out on Tuesday.",                    })                }            >                Info            </Button>            <Button                variant="outline"                onClick={() =>                    toast.add({                        type: "error",                        title: "Couldn't send the remittance",                        description:                            "Halcyon Packaging's remit-to address bounced. Check it on their supplier record.",                        actionProps: {                            children: "Try again",                            onClick: () =>                                toast.add({                                    type: "success",                                    title: "Remittance sent",                                    description: "Halcyon Packaging, PAY-1179.",                                }),                        },                    })                }            >                Error            </Button>        </div>    );}
Toast types
TypeIconUse forTitle pattern
successCircle checkSomething finished that the screen can't show.Past tense: Remittance sent
noneNoneA reversible change, with Undo.Past tense, names the record: INV-20451 archived
infoInfoSomething the person should know that isn't a result: a lock, a schedule change, work that started.A statement: Thursday's run locks at noon
errorOctagon in Signal RedAn action failed for a reason that isn't a field.Couldn't plus the verb and the thing
Toast behavior
BehaviorValue
PositionBottom right from 640px, full width with 16px insets below, up to 24rem wide.
Duration5 seconds, paused while hovered or focused. Swipe to dismiss.
StackAt most 3 visible. Older toasts tuck behind, 12px apart and scaled 10% per step.
ActionOne outline button: Undo, Try again, Download, View. Never two.
AnnouncementPolite by default. Errors that block work can pass priority: "high".

What not to toast#

A toast is an interruption in the corner of the eye. Spend it only where nothing else answers.

What not to toast
Don't toastInstead
A value that changed in front of the personLet the value change. Update its meta.
AutosaveA quiet Draft saved just now beside the field.
Navigation, opening a panel, switching a tabNothing. The new screen is the answer.
A rejected fieldThe field error, under the field.
A failed readThe component's error state with Retry.
A condition that lastsAn Alert in the page until it's resolved.
Each row of a bulk actionOne toast with the count: 12 invoices approved.
Success of an optimistic row actionNothing. The row already changed. Toast only the rollback.

Known gaps

Toasts have 16px corners, a CSS border plus shadow-lg, and 500ms transforms that ignore reduced motion; DESIGN.md asks for 10px overlay corners, one composite shadow and the spring tokens. The info icon is drawn in the text color rather than Note Blue. All recorded on Toast.

Field errors#

When the server rejects a value, the answer goes under that field, exactly like a client-side error. It never goes to a toast, which would vanish before the fix.

Invite Jordan to see a server error land on the field. Any other address answers with a toast.

import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function FieldErrorFeedback() {    const id = React.useId();    const [email, setEmail] = React.useState("jordan.lee@cedarline.com");    const [error, setError] = React.useState<string | undefined>();    const [pending, setPending] = React.useState(false);    return (        <form            noValidate            className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"            onSubmit={(event) => {                event.preventDefault();                if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {                    setError(                        "Enter an email address, like wen.zhou@cedarline.com.",                    );                    document.getElementById(id)?.focus();                    return;                }                setPending(true);                window.setTimeout(() => {                    setPending(false);                    if (                        email.trim().toLowerCase() ===                        "jordan.lee@cedarline.com"                    ) {                        setError(                            "Jordan Lee already approves runs over $25,000. Pick someone else.",                        );                        document.getElementById(id)?.focus();                        return;                    }                    setError(undefined);                    toast.add({                        type: "success",                        title: "Approver invited",                        description: `${email.trim()} approves runs over $25,000 from Thursday.`,                    });                    setEmail("");                }, 700);            }}        >            <Field>                <FieldLabel htmlFor={id}>Second approver</FieldLabel>                <div className="flex gap-2">                    <Input                        id={id}                        type="email"                        value={email}                        onChange={(event) => {                            setEmail(event.target.value);                            if (error) setError(undefined);                        }}                        aria-invalid={error ? true : undefined}                        aria-describedby={error ? `${id}-error` : undefined}                    />                    <PendingButton                        type="submit"                        variant="outline"                        pending={pending}                    >                        Invite                    </PendingButton>                </div>                <FieldError id={`${id}-error`} className="text-xs">                    {error}                </FieldError>            </Field>            <p className="text-xs text-muted-foreground">                Invite Jordan to see a server error land on the field. Any other                address answers with a toast.            </p>        </form>    );}
  • Mark the control aria-invalid, show the message under it and move focus back to it.
  • Say what's wrong and what to do: Jordan Lee already approves runs over $25,000. Pick someone else.
  • Clear the error as soon as the value changes. See Forms and validation for timing.

Banners for page state#

An Alert describes a condition that is still true: a lost bank connection, a run waiting on approval. It stays in the layout until the condition ends, then goes, and the resolution gets a toast.

RUN-0932 is waiting on a second approver
Runs over $25,000 need two approvals. Wen Zhou has been asked and the run locks at noon on Thursday.
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@oration/canon/components/alert";import { Button } from "@oration/canon/components/button";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import { CircleAlertIcon, InfoIcon, RotateCcwIcon } from "lucide-react";import * as React from "react";export function Banners() {    const [connected, setConnected] = React.useState(false);    const [pending, setPending] = React.useState(false);    return (        <div className="flex w-full max-w-xl flex-col gap-3 text-left">            {connected ? null : (                <Alert                    variant="destructive"                    className="has-data-[slot=alert-action]:pr-28"                >                    <CircleAlertIcon aria-hidden="true" />                    <AlertTitle>Bank connection lost</AlertTitle>                    <AlertDescription>                        Payments from the operating account are paused. Three                        runs are waiting.                    </AlertDescription>                    <AlertAction>                        <PendingButton                            size="sm"                            variant="outline"                            pending={pending}                            onClick={() => {                                setPending(true);                                window.setTimeout(() => {                                    setPending(false);                                    setConnected(true);                                    toast.add({                                        type: "success",                                        title: "Operating account reconnected",                                        description:                                            "Three paused runs are sending now.",                                    });                                }, 900);                            }}                        >                            Reconnect                        </PendingButton>                    </AlertAction>                </Alert>            )}            <Alert role="status">                <InfoIcon aria-hidden="true" />                <AlertTitle>                    RUN-0932 is waiting on a second approver                </AlertTitle>                <AlertDescription>                    Runs over $25,000 need two approvals. Wen Zhou has been                    asked and the run locks at noon on Thursday.                </AlertDescription>            </Alert>            {connected ? (                <Button                    variant="ghost"                    size="sm"                    className="self-start"                    onClick={() => setConnected(false)}                >                    <RotateCcwIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Reset example                </Button>            ) : null}        </div>    );}
  • Place it at the top of the page or of the section it concerns, never floating.
  • The title states the condition; the description says the consequence; one action fixes it.
  • Use the destructive variant for conditions that stop work (payments paused) and the default for ones that need attention.
  • Pass role="status" for banners present when the page loads. Keep role="alert", the default, for ones that appear because something just broke.
  • At most one banner per region. Two banners means one of them is a toast.

Alert draws its edge with a CSS border on Card White and has no warning or info variants. DESIGN.md would draw an inline notice as a tint well. Recorded on Alert.

Timing#

Match the feedback to how long the work takes. Fast work shows only its result; slow work shows progress; long work moves to the background.

Under 200ms

The change is the answer. No spinner, no toast.

0 matched

About a second

A pending button holds its width, then a toast.

Many seconds

Say it started, free the screen, and toast when it's done.

import { Button } from "@oration/canon/components/button";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Timing() {    const [sending, setSending] = React.useState(false);    const [exporting, setExporting] = React.useState(false);    const [matched, setMatched] = React.useState(0);    return (        <div className="grid w-full gap-3 text-left md:grid-cols-3">            <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                <div>                    <p className="text-13 font-medium">Under 200ms</p>                    <p className="text-xs text-muted-foreground">                        The change is the answer. No spinner, no toast.                    </p>                </div>                <p className="text-13 tabular-nums">{matched} matched</p>                <Button                    variant="outline"                    size="sm"                    className="mt-auto self-start"                    onClick={() => setMatched((n) => n + 1)}                >                    Match next invoice                </Button>            </div>            <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                <div>                    <p className="text-13 font-medium">About a second</p>                    <p className="text-xs text-muted-foreground">                        A pending button holds its width, then a toast.                    </p>                </div>                <PendingButton                    variant="outline"                    size="sm"                    pending={sending}                    className="mt-auto self-start"                    onClick={() => {                        setSending(true);                        window.setTimeout(() => {                            setSending(false);                            toast.add({                                type: "success",                                title: "Remittance sent",                                description: "Orchard Street Foods, PAY-1185.",                            });                        }, 1200);                    }}                >                    Send remittance                </PendingButton>            </div>            <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                <div>                    <p className="text-13 font-medium">Many seconds</p>                    <p className="text-xs text-muted-foreground">                        Say it started, free the screen, and toast when                        it&apos;s done.                    </p>                </div>                <Button                    variant="outline"                    size="sm"                    disabled={exporting}                    className="mt-auto self-start"                    onClick={() => {                        setExporting(true);                        toast.add({                            type: "info",                            title: "Exporting September invoices",                            description:                                "1,284 invoices. We'll tell you when the file is ready.",                        });                        window.setTimeout(() => {                            setExporting(false);                            toast.add({                                type: "success",                                title: "September export is ready",                                description:                                    "cedarline-invoices-2026-09.csv, 1,284 rows.",                                actionProps: {                                    children: "Download",                                    onClick: () =>                                        toast.add({                                            title: "Downloading cedarline-invoices-2026-09.csv",                                        }),                                },                            });                        }, 4000);                    }}                >                    {exporting ? "Exporting" : "Export September"}                </Button>            </div>        </div>    );}
Feedback by duration
TakesShowThen
Under 200msNothing extra. useDelayedFlag(pending, 200) keeps a spinner from flashing.The change in place.
200ms to a few secondsA Pending button: spinner over the label, width held.The change, or a toast if it happened elsewhere.
Longer, or out of the person's handsAn info toast that it started, and free the screen.A success toast with the result and one action.
Running while watchedLive state in place: shimmer, progress, counts.See Live and running states.

Do and don't#

MatchMatched to PO 4471
Do. Let the row answer. The status changes where the person clicked.
MatchMatched to PO 4471
Status updated successfully
Don't. Toast Status updated for a change that is already visible under the cursor.
Do. Name the thing and the outcome: Remittance sent, Couldn't send the remittance.
Don't. Write Success! or An error occurred. Neither says what happened.
Do. Give a failure one way forward: Try again, or where to fix it.
Don't. Report a failure with no action and no reason.

Accessibility#

Feedback that only moves pixels is invisible to a screen reader.

  • Toasts are announced politely through Base UI's toast region. Their action is a real button, and the timer pauses while the toast has focus.
  • Inline changes that matter (an assignee, a match result) sit in or beside a polite live region, such as the updated meta line.
  • Field errors link to their control with aria-describedby, and focus returns to the field.
  • Alerts present on load use role="status"; ones that appear because something broke keep role="alert".
  • A pending button keeps focus and exposes its busy state, so pressing it twice does nothing.
  • Status is never color alone. Every dot travels with a label.

Components involved#

The parts this pattern is built from.

Components involved
ComponentRole here
ToastSuccess, info, error and Undo.
AlertBanners for conditions that persist.
FieldFieldError for rejected values.
Pending buttonProgress on the control that started it.
Status labelThe changed state, with a label beside the color.
Data stateuseDelayedFlag to hold back spinners; error states for reads.