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
Answer where they're looking
Reversible means Undo
Decision guide#
Where the answer goes, by what happened.
| What happened | Answer with | Example |
|---|---|---|
| A value changed where the person is looking | The value itself, plus updated meta | Assignee reads Jordan Lee, Assigned just now |
| Something left the screen or happened elsewhere | Success toast | Remittance sent |
| A reversible removal or change | Toast with Undo | Removed INV-20431 from the run |
| A field's value was rejected | Field error under the control | Jordan Lee already approves runs over $25,000. |
| An action failed for a reason that isn't a field | Error toast with Try again | Couldn't send the remittance |
| A condition persists until someone acts | Alert at the top of the page or section | Bank connection lost |
| A read failed | The component's own error state | See Loading, empty and error |
| Background work started and will finish later | Info toast now, success toast when done | September 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
- 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> );}| Type | Icon | Use for | Title pattern |
|---|---|---|---|
success | Circle check | Something finished that the screen can't show. | Past tense: Remittance sent |
| none | None | A reversible change, with Undo. | Past tense, names the record: INV-20451 archived |
info | Info | Something 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 |
error | Octagon in Signal Red | An action failed for a reason that isn't a field. | Couldn't plus the verb and the thing |
| Behavior | Value |
|---|---|
| Position | Bottom right from 640px, full width with 16px insets below, up to 24rem wide. |
| Duration | 5 seconds, paused while hovered or focused. Swipe to dismiss. |
| Stack | At most 3 visible. Older toasts tuck behind, 12px apart and scaled 10% per step. |
| Action | One outline button: Undo, Try again, Download, View. Never two. |
| Announcement | Polite 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.
| Don't toast | Instead |
|---|---|
| A value that changed in front of the person | Let the value change. Update its meta. |
| Autosave | A quiet Draft saved just now beside the field. |
| Navigation, opening a panel, switching a tab | Nothing. The new screen is the answer. |
| A rejected field | The field error, under the field. |
| A failed read | The component's error state with Retry. |
| A condition that lasts | An Alert in the page until it's resolved. |
| Each row of a bulk action | One toast with the count: 12 invoices approved. |
| Success of an optimistic row action | Nothing. 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.
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.
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'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> );}| Takes | Show | Then |
|---|---|---|
| Under 200ms | Nothing extra. useDelayedFlag(pending, 200) keeps a spinner from flashing. | The change in place. |
| 200ms to a few seconds | A Pending button: spinner over the label, width held. | The change, or a toast if it happened elsewhere. |
| Longer, or out of the person's hands | An info toast that it started, and free the screen. | A success toast with the result and one action. |
| Running while watched | Live state in place: shimmer, progress, counts. | See Live and running states. |
Do and don't#
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 keeprole="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.
| Component | Role here |
|---|---|
| Toast | Success, info, error and Undo. |
| Alert | Banners for conditions that persist. |
| Field | FieldError for rejected values. |
| Pending button | Progress on the control that started it. |
| Status label | The changed state, with a label beside the color. |
| Data state | useDelayedFlag to hold back spinners; error states for reads. |