UndoToast
"Do it, then offer Undo": a toast with the message, an Undo button (⌘/Ctrl+Z), an optional countdown and late commit — the real write runs in onCommit only if nobody undoes.
import { UndoToast } from "@cynco-labs/ui"; Example
import { useEffect, useState } from "react";
import {
ActionBar,
Button,
DataList,
DocStatus,
INVOICE_STATUS,
Money,
UndoToastProvider,
useUndoToast,
} from "@cynco-labs/ui";
const START = [
{
id: "1",
no: "INV-2026-0141",
customer: "Tropika Kopi Enterprise",
amount: "3240.00",
status: "overdue",
},
{
id: "2",
no: "INV-2026-0139",
customer: "Pelita Hartanah Sdn Bhd",
amount: "7450.50",
status: "overdue",
},
{
id: "3",
no: "INV-2026-0137",
customer: "Klinik Sentosa Sdn Bhd",
amount: "5600.00",
status: "draft",
},
{
id: "4",
no: "INV-2026-0136",
customer: "Cahaya Solar Teknik",
amount: "21375.25",
status: "overdue",
},
];
function Demo() {
const toast = useUndoToast();
const [rows, setRows] = useState(START);
const [pending, setPending] = useState([]);
const [log, setLog] = useState([
"Try: Void → Undo (or Ctrl/⌘+Z) · hover pauses the countdown · F8 focuses the toast, Esc dismisses",
]);
function voidIds(ids) {
if (!ids.length) return;
const nos = rows.filter((x) => ids.indexOf(x.id) >= 0).map((x) => x.no);
setPending((ps) => ps.concat(ids));
toast.show({
message:
ids.length === 1 ? (
<span>
<span className="font-mono text-xs">{nos[0]}</span>
{" voided"}
</span>
) : (
ids.length + " invoices voided"
),
onUndo: () => {
setPending((ps) => ps.filter((x) => ids.indexOf(x) < 0));
setLog((L) => ["Undone: " + nos.join(", ") + " restored"].concat(L).slice(0, 3));
},
onCommit: () => {
setPending((ps) => ps.filter((x) => ids.indexOf(x) < 0));
setRows((rs) =>
rs.map((x) => (ids.indexOf(x.id) >= 0 ? Object.assign({}, x, { status: "voided" }) : x)),
);
setLog((L) =>
["Committed: " + nos.join(", ") + " voided on the server"].concat(L).slice(0, 3),
);
},
});
}
useEffect(() => {
voidIds(["3"]);
}, []);
const open = rows.filter((x) => x.status !== "voided" && pending.indexOf(x.id) < 0);
const overdue = open
.filter((x) => x.status === "overdue")
.slice(0, 2)
.map((x) => x.id);
const cols = [
{
key: "no",
header: "Invoice #",
mobile: "title",
nowrap: true,
cell: (x) => <span className="font-mono text-xs text-primary">{x.no}</span>,
},
{ key: "customer", header: "Customer", cell: (x) => x.customer },
{
key: "status",
header: "Status",
mobile: "badge",
cell: (x) =>
pending.indexOf(x.id) >= 0 ? (
<span className="text-xs text-tertiary-foreground">Voiding…</span>
) : (
<DocStatus status={x.status} config={INVOICE_STATUS} />
),
},
{
key: "amount",
header: "Amount",
align: "right",
mobile: "value",
cell: (x) => <Money amount={x.amount} />,
},
];
return (
<div className="flex flex-col gap-3">
<ActionBar>
<Button
variant="secondary"
disabled={!overdue.length}
onClick={() => {
voidIds(overdue);
}}
>
{"Void " + overdue.length + " overdue"}
</Button>
<Button
variant="skeleton"
onClick={() => {
setRows(START);
setPending([]);
}}
>
Reset
</Button>
</ActionBar>
<DataList
columns={cols}
rows={rows}
getKey={(x) => x.id}
caption="Invoices"
dense
rowClassName={(x) => (pending.indexOf(x.id) >= 0 ? "line-through decoration-tertiary-foreground" : undefined)}
trailing={(x) =>
x.status === "voided" || pending.indexOf(x.id) >= 0 ? null : (
<Button
variant="skeleton"
size="sm"
aria-label={"Void " + x.no}
onClick={() => {
voidIds([x.id]);
}}
>
Void
</Button>
)
}
/>
<ul className="flex flex-col gap-0.5 text-xs text-tertiary-foreground">
{log.map((t, i) => (
<li key={i + t}>{t}</li>
))}
</ul>
</div>
);
}
export default function UndoToastDemo() {
return (
<UndoToastProvider position="bottom-right">
<Demo />
</UndoToastProvider>
);
} Use
Mount UndoToastProvider once near the app root. On a destructive action, update the UI optimistically and call useUndoToast().show({message, onUndo, onCommit}); do the server call in onCommit. Keep real confirm dialogs only for actions that can't be held back (e.g. submitting to LHDN).
Props
UndoToastProvider: children, max (default 3; a 4th toast commits the oldest), duration (ms, default Infinity: stays until dismissed, undone or pushed out, per WCAG 2.2.1), viewport (default true; false to place <UndoToastViewport/> yourself), position (bottom-center | bottom-right | bottom-left). useUndoToast() → {show({message, onUndo, onCommit?, duration?, undoLabel?, id?}) → id, undo(id?), dismiss(id), count}. UndoToastViewport: position, className, section props. UndoToast (the presentational item): message, onUndo, onDismiss, onExpire, duration, undoLabel, showShortcut, paused, className, li props.
Notes
Keyboard: ⌘/Ctrl+Z undoes the newest toast (left alone while typing in a field, where it is the field's own undo); F8 jumps to the newest Undo button; Esc on a focused toast dismisses (commits) it; focus returns to where it was. The countdown pauses on hover, on focus and while the tab is hidden; the stack is an aria-live="polite" list. Each toast settles exactly once; anything pending commits when the provider unmounts or on pagehide. No toast library — the countdown is a CSS animation whose end commits. Width is min(24rem, viewport − 2rem); hidden in print.