Undo Toast
Feedback

Undo Toast

A commit-on-timeout toast: the action fires only when the depleting ring finishes, Undo cancels it, and hover, focus, or a hidden tab pauses the countdown.

Install

npx shadcn@latest add @paragon/undo-toast

undo-toast.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface UndoToastProps
  extends Omit<
    React.ComponentProps<"div">,
    // Motion owns these drag/animation handlers on motion.div.
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
  > {
  open: boolean;
  /** Toast message. */
  message: string;
  /** ms before the action commits. */
  duration?: number;
  /** Runs when the countdown completes uninterrupted. */
  onCommit?: () => void;
  /** Runs when the user hits Undo. */
  onUndo?: () => void;
  /** Undo button label. */
  undoLabel?: string;
}

const R = 9;
const CIRCUMFERENCE = 2 * Math.PI * R;

/**
 * A commit-on-timeout toast: the destructive action fires only when the
 * depleting ring around the Undo button finishes; pressing Undo cancels it.
 * The ring depletes on a single linear keyframe (the sanctioned constant
 * motion) and pauses — together with the commit — on hover, focus, AND
 * hidden tabs via animation-play-state, so a hesitating or tabbed-away user
 * is never rushed. The countdown is driven off the animation itself
 * (animationend commits), keeping ring and commit perfectly in sync. The
 * toast uses the house enter with a half-duration exit. Under reduced motion
 * the ring holds full and the commit runs on a pausable timer.
 */
export function UndoToast({
  open,
  message,
  duration = 5000,
  onCommit,
  onUndo,
  undoLabel = "Undo",
  className,
  ...props
}: UndoToastProps) {
  const reducedMotion = useReducedMotion();
  const [pointerPaused, setPointerPaused] = React.useState(false);
  const [pageHidden, setPageHidden] = React.useState(false);
  const paused = pointerPaused || pageHidden;

  // Once undone, neither the timer nor a still-finishing animation may commit.
  const cancelled = React.useRef(false);
  const remaining = React.useRef(duration);
  const onCommitRef = React.useRef(onCommit);
  React.useEffect(() => {
    onCommitRef.current = onCommit;
  });

  // Pause while the tab is hidden — an invisible countdown isn't honest.
  React.useEffect(() => {
    const onVisibility = () =>
      setPageHidden(document.visibilityState === "hidden");
    onVisibility();
    document.addEventListener("visibilitychange", onVisibility);
    return () => document.removeEventListener("visibilitychange", onVisibility);
  }, []);

  // Fresh countdown per presentation.
  React.useEffect(() => {
    setPointerPaused(false);
    if (open) cancelled.current = false;
    remaining.current = duration;
  }, [open, duration]);

  // Reduced motion has no ring animation, so drive the commit off a timer
  // that banks the elapsed time across pauses.
  React.useEffect(() => {
    if (!open || !reducedMotion || paused) return;
    const startedAt = Date.now();
    const timer = setTimeout(() => {
      if (!cancelled.current) onCommitRef.current?.();
    }, remaining.current);
    return () => {
      clearTimeout(timer);
      remaining.current = Math.max(
        0,
        remaining.current - (Date.now() - startedAt),
      );
    };
  }, [open, paused, reducedMotion, duration]);

  return (
    <AnimatePresence>
      {open && (
        <motion.div
          role="status"
          aria-live="polite"
          data-slot="undo-toast"
          initial={{ opacity: 0, y: 12, filter: "blur(4px)" }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={{
            opacity: 0,
            y: 12,
            filter: "blur(4px)",
            transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
          }}
          transition={{ type: "spring", duration: 0.35, bounce: 0 }}
          onMouseEnter={() => setPointerPaused(true)}
          onMouseLeave={() => setPointerPaused(false)}
          onFocus={() => setPointerPaused(true)}
          onBlur={() => setPointerPaused(false)}
          className={cn(
            "flex items-center gap-3 rounded-xl bg-popover px-4 py-3 shadow-overlay",
            className,
          )}
          {...props}
        >
          <style href="paragon-undo-toast" precedence="paragon">{`
            @keyframes paragon-undo-toast-deplete {
              from { stroke-dashoffset: 0; }
              to { stroke-dashoffset: ${CIRCUMFERENCE}; }
            }
          `}</style>
          <span className="text-sm text-popover-foreground">{message}</span>
          <button
            type="button"
            onClick={() => {
              cancelled.current = true;
              onUndo?.();
            }}
            className="relative ml-2 inline-flex items-center gap-2 rounded-md px-1 text-sm font-medium text-popover-foreground outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover after:absolute after:top-1/2 after:-inset-x-2 after:h-10 after:-translate-y-1/2"
          >
            <span className="relative inline-flex size-6 items-center justify-center">
              <svg
                className="absolute inset-0 -rotate-90"
                viewBox="0 0 24 24"
                aria-hidden
              >
                <circle
                  cx="12"
                  cy="12"
                  r={R}
                  fill="none"
                  stroke="currentColor"
                  strokeOpacity={0.2}
                  strokeWidth={2}
                />
                <circle
                  cx="12"
                  cy="12"
                  r={R}
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2}
                  strokeLinecap="round"
                  strokeDasharray={CIRCUMFERENCE}
                  strokeDashoffset={0}
                  onAnimationEnd={() => {
                    if (!cancelled.current) onCommit?.();
                  }}
                  style={
                    reducedMotion
                      ? undefined
                      : {
                          animation: `paragon-undo-toast-deplete ${duration}ms linear forwards`,
                          animationPlayState: paused ? "paused" : "running",
                        }
                  }
                />
              </svg>
            </span>
            {undoLabel}
          </button>
        </motion.div>
      )}
    </AnimatePresence>
  );
}