Undo Toast
An optimistic-delete toast: the row disappears at once, a countdown ring holds the destructive commit, and hover or focus pauses the window.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/undo-toast.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "UndoToast" component with lucide-react
icons and no other dependencies. It is the toast that stands between an
optimistic delete and the mutation that makes it real: the row is already gone
from the list, and this card decides whether the DELETE ever leaves the browser.
Its one promise: the destructive callback fires EXACTLY ONCE, and never before
the undo window has closed.
Contract
- export const UndoToast = forwardRef<HTMLDivElement, UndoToastProps>, where
UndoToastProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children">.
- Props (defaults):
- label: string — past tense, the change already happened ("INV-1042 deleted").
- description?: string — second line; where the change landed.
- duration = 6000 (ms) — length of the undo window. Anything that is not a
positive finite number (0, Infinity, NaN) means "no deadline": the card waits
for a human and never commits on its own. Read once, on mount; change `key`
to restart a window.
- undoLabel = "Undo", committedLabel = "Change applied",
undoneLabel = "Restored" — flat strings, so the surface translates without a
nested config object.
- dismissAction: "commit" | "hide" = "commit" — what the close button means.
- showDismiss = true, tone: "default" | "destructive" = "default".
- paused = false — freeze the countdown from outside (offline, blocking modal).
- commitOnUnload = true.
- onCommit?: () => void — the real, irreversible work.
- onUndo?: () => void — put the row back.
- onResolve?: (resolution: "undone" | "committed") => void — fires once, after
whichever of the two above ran. This is where the consumer unmounts the card.
- export function UndoToastGroup — an optional coordinator, NOT a queue: the
consumer already owns the pending list, because they are the one who removed
the row optimistically. Props: label = "Undo notifications",
undoShortcut = true, plus div attributes. It renders in normal flow; pinning is
the consumer's job via className ("fixed bottom-4 right-4 z-50 w-80").
- The component never renders the list, never fetches, never removes a row. It
owns exactly one thing: when the commit is allowed to happen.
Behavior — the window
- Keep the remaining milliseconds in a ref, not in state, and hold the deadline
inside one effect keyed on [hidden, reduced, running, settle]:
running = window is finite AND not settled AND not `paused` AND not
self-paused (hover/focus) AND not group-paused-while-visible.
On setup: deadline = performance.now() + remainingRef.current. On cleanup:
clearTimeout / clearInterval / cancelAnimationFrame, then bank
remainingRef.current = max(0, deadline - performance.now()). Banking is what
makes resume continue the window instead of restarting it — hovering three
times must not hand out three fresh countdowns.
- setTimeout owns the commit; requestAnimationFrame only paints. Frames stop in
a background tab while timeouts merely throttle, and "the reader switched
tabs" must not postpone a delete indefinitely.
- Pausing on hover AND on focus is not a nicety here. Without it the undo is
unreachable for a slow reader and for anyone tabbing towards the button, which
makes the whole pattern a lie. Track pointer and focus separately (onFocus /
onBlur with a currentTarget.contains(relatedTarget) check) and pause if either
is inside.
- settle(reason) is the only exit. reason "undo" resolves to "undone" and calls
onUndo; "expire" | "dismiss" | "unload" resolve to "committed" and call
onCommit; then onResolve runs. The guard is a ref holding the resolution, read
AND written synchronously at the top of settle. A state flag is not enough:
two clicks on Undo land in the same tick, long before React re-renders, and
the second one would fire the mutation again.
- Close button: dismissAction "commit" flushes immediately ("yes, I'm sure").
"hide" only clears the screen — the card returns <div hidden>, stays mounted,
keeps counting and still commits on time. When hiding, reset the hover and
focus flags by hand: nothing can be un-hovered once it is off screen, so the
last hover would otherwise freeze that window forever.
- pagehide (not beforeunload, which disqualifies the page from the back/forward
cache) flushes the pending commit. The handler is synchronous, so the real
request belongs in navigator.sendBeacon() or fetch(…, { keepalive: true }).
- Unmount is deliberately NOT a commit. StrictMode mounts, unmounts and remounts
every effect in development, so an unmount flush would fire the DELETE before
the visitor ever saw the toast. If a client-side route change must commit, call
your own pending callbacks from the route handler.
Behavior — the ring maths
- radius 9 inside a 24×24 viewBox, so circumference = 2π·9 ≈ 56.55. Two circles:
a track at opacity .2 and an arc with strokeDasharray = circumference and
strokeDashoffset = circumference × (1 - remaining/duration). Rotate the svg
-90° so the arc drains from twelve o'clock.
- strokeDashoffset is NEVER written from JSX. The frame loop writes
arc.style.strokeDashoffset directly; leaving the attribute out means offset 0,
a full ring, which happens to be the correct first paint and the correct SSR
output. State only holds the whole-second numeral, updated with a functional
setState that returns `prev` unchanged when the integer has not moved — React
bails out, so a 60fps arc costs one render per second, not sixty.
- Whenever the loop is off (paused, sticky, first paint) an effect with no
dependency array repaints the arc from the banked remainder, so exactly one
owner writes it at any time.
Behavior — the group
- Two contexts, not one: a stable api context (register / recheckFocus) and a
paused context. Merged, every hover would change the api identity and
re-register (and therefore re-order) every card.
- Each card takes a monotonic seq from a lazy useState initialiser and registers
{ seq, undo, isPending } in an effect. Ctrl/Cmd+Z undoes the pending card with
the highest seq. Skip the shortcut when the event target is an input, textarea,
select or contenteditable — there the platform's own undo owns the chord.
- Hovering or focusing anywhere in the group pauses every countdown in it, so the
toast being read never has a sibling expire and shift the layout under the
pointer. Each card still banks its own remainder, so their deadlines stay
independent.
- When the focused button disappears together with its own toast the browser
fires no focusout at all, and the pause flag would stay true forever. Re-derive
it from document.activeElement on the next animation frame (cancelled on
unmount) instead of trusting the event.
- That same removal leaves document.activeElement on <body>, one keystroke after
the reader pressed Undo, so the frame that re-derives the flag also hands focus
back: the card that took the departing one's slot, else the last card left,
else the region itself (tabindex="-1" added at that moment and removed again on
blur, so a stray click on the group's padding can never focus it and freeze
every countdown). Do it only when the control that left was :focus-visible —
a pointer user who clicked Undo did not ask for focus, and parking it on a
sibling card would pause the whole group until they clicked something else.
Keyboard and ARIA
- Tab reaches Undo then the close button, in that order; Enter / Space activate
them. Entering the card pauses the countdown, leaving it resumes.
- Ctrl/Cmd+Z is the group-level undo of the newest pending toast.
- The card NEVER takes focus on mount. Auto-focusing Undo would pause the
countdown the instant the toast appeared and freeze the window forever; the
keyboard shortcut is what replaces that trip.
- Once settled, Undo and the close button stay mounted with aria-disabled and a
handler that returns early. Never the native `disabled` attribute and never
unmount them: the browser blurs a control the moment it is disabled, and focus
can be sitting on that exact button.
- The group is role="region" aria-live="polite"; a standalone card falls back to
role="status" so its resolution is still announced. The card is
aria-atomic="false" (role="status" implies true, which would re-read the whole
card on every change) and aria-labelledby its own title.
- The entire dial is aria-hidden — a numeral changing every second inside a live
region would be read out every second. Instead the Undo button is
aria-describedby a STATIC sr-only sentence: "Undo stays available for N
seconds. Moving focus into this notice pauses the countdown." Static means it
is never re-announced.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground card with border,
shadow-lg and rounded-xl; ring track and arc are stroke-current under a
text-primary (tone "default") or text-destructive (tone "destructive") parent;
a paused ring switches to text-muted-foreground, which is the whole "it
stopped" signal; secondary text is text-muted-foreground; buttons hover into
bg-accent / text-accent-foreground and carry focus-visible:ring-2 ring-ring.
- Entrance keyframe only (fade + 8px rise + 0.98 scale) shipped through a React
19 hoisted <style href precedence> tag. There is deliberately no exit
animation: the consumer owns the list and unmounts the card, and a self-played
exit would fight their list transition.
- prefers-reduced-motion (read through useSyncExternalStore over a matchMedia
query, with a false server snapshot): no entrance animation, and the arc is
sampled by a 1000ms interval instead of a frame loop, so it steps once a
second instead of sweeping. The countdown, the pause, the undo and the commit
are untouched — reduced motion loses the sweep, not a feature.
- Merge the consumer className last with cn(); spread the remaining props on the
root; expose data-state="running | paused | undone | committed" for styling
hooks.
- Degenerate cases: duration NaN/0/Infinity renders an ∞ glyph and no deadline;
seconds are ceil'ed and clamped at 0 so the numeral never shows -1; a settled
card renders a RotateCcw (undone) or Trash2 (committed) glyph in place of the
ring; a hidden card renders <div hidden> and nothing else.
Customization levers
- Window length: `duration` is the only knob that matters. 5–7s is the readable
default; go to 10s+ for bulk actions, Infinity for a commit that must be
confirmed by hand (the ring becomes a static ∞ and only Undo or × resolve it).
- Close-button semantics: dismissAction="commit" for "yes, I'm sure, go now";
"hide" when the commit is batched server-side anyway and × should only clear
the screen.
- Tone: "destructive" for deletes, "default" for reversible moves and archives;
it only swaps the ring/glyph colour token, no layout branch.
- Anatomy: description, the close button (showDismiss) and the ring numeral are
independent slots — drop the description for terse toasts, drop × when the
window is short enough that it does not need one.
- Dial size and weight: RING_RADIUS and the strokeWidth pair are two constants;
the circumference recomputes from the radius, so nothing else changes. Swap
the numeral for a bar by keeping the same fraction and driving a width.
- Placement: UndoToastGroup renders in flow. className pins it
("fixed bottom-4 right-4 z-50 w-80"), or drop it inline under the list it
belongs to; on mobile prefer a single full-width group at the bottom.
- Wiring: onCommit is your mutation, onUndo is your local rollback, onResolve is
where the card leaves the list. Restore by the row's original index (a
Map from id to position), not by appending — a row that comes back at the
bottom of the list reads as a second bug.Concepts
- Undo window instead of a confirmation — the action happens first and the toast sells back the seconds in which it can be taken away. It is the right trade whenever the answer to "are you sure?" is almost always yes; the cost of being wrong is one click, not a dialog on every single delete.
- Pause is the contract, not a flourish — a countdown that keeps running while somebody reads the toast is a countdown they cannot beat. Hover and focus both freeze it, and because focus freezes it, the card must never focus itself on mount or the window would never close at all.
- One-shot ref guard — the resolution is written to a ref synchronously at the top of
settle, so a double click, a click racing the expiry timeout, and an expiry racingpagehideall collapse into a single call. A state flag would let the second one through, because both land before React re-renders. - Timeout owns the deadline, frames own the arc —
requestAnimationFramestops in a background tab, so the commit hangs offsetTimeoutand the ring is purely cosmetic. Under reduced motion the same value is sampled once a second and the ring steps instead of sweeping; nothing about the timing changes. - The consumer owns the list — the component never removes a row, because the row is already gone: optimistic removal is what makes the toast necessary in the first place.
onUndoputs it back at its original index,onResolveis where the card leaves the screen. - Focus outlives the card — the button a keyboard reader pressed Undo on leaves the DOM with its own toast, and the browser answers by dropping focus on
<body>: one keystroke, and they are back at the top of the document. The group catches that on the next frame and lands focus on the card that took the departing one's slot, or on the region itself once the last one goes. A pointer user is left alone on purpose — focus is also what pauses the countdowns. - Unload commits, unmount does not —
pagehideflushes every pending window, because a destructive intent left in limbo is worse than one carried out. Unmount is deliberately excluded: StrictMode remounts every effect in development, and an unmount flush would fire the mutation before the visitor ever saw the toast.
Pull To Refresh
A pull-down-to-refresh wrapper: a rubber-banded drag claimed only while the scroller is at the top, a threshold that arms the release, an async refresh held until it settles, and a keyboard-reachable refresh button.
Dock
A macOS-style dock — icon tiles magnify as the cursor approaches, with spring physics and pure-CSS label bubbles.