Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { InfinityIcon, RotateCcw, Trash2, Undo2, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entrance only. There is no exit keyframe on purpose: the consumer owns the
 * pending list, so the card is unmounted by *their* `onResolve` handler and a
 * self-played exit would fight whatever list animation they already have.
 */
const KEYFRAMES = `@keyframes zut-in{from{opacity:0;transform:translateY(8px) scale(0.98)}}`

/** Ring geometry inside the 24×24 viewBox the SVG is drawn in. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/undo-toast.json

Prompt

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 racing pagehide all 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 arcrequestAnimationFrame stops in a background tab, so the commit hangs off setTimeout and 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. onUndo puts it back at its original index, onResolve is 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 notpagehide flushes 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.

On This Page