Feedback

Undo Toast

A commit-on-timeout toast: the action fires only when the depleting ring finishes, Undo cancels it, and hover 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 both the keyframe and the commit timer pause together on hover/focus via
 * animation-play-state, so a hesitating 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/exit. Under reduced
 * motion the ring holds full and the commit fires on a plain timer.
 */
export function UndoToast({
  open,
  message,
  duration = 5000,
  onCommit,
  onUndo,
  undoLabel = "Undo",
  className,
  ...props
}: UndoToastProps) {
  const reducedMotion = useReducedMotion();
  const [paused, setPaused] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout>>(null);
  const remaining = React.useRef(duration);
  const startedAt = React.useRef(0);

  const clear = React.useCallback(() => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = null;
  }, []);

  // Reduced motion has no ring animation, so drive the commit off a timer.
  const start = React.useCallback(
    (ms: number) => {
      clear();
      startedAt.current = Date.now();
      timer.current = setTimeout(() => onCommit?.(), ms);
    },
    [clear, onCommit],
  );

  React.useEffect(() => {
    setPaused(false);
    if (!open) {
      clear();
      remaining.current = duration;
      return;
    }
    if (reducedMotion) {
      remaining.current = duration;
      start(duration);
    }
    return () => clear();
  }, [open, duration, reducedMotion, start, clear]);

  React.useEffect(() => () => clear(), [clear]);

  const handlePause = () => {
    if (!open) return;
    if (reducedMotion) {
      remaining.current -= Date.now() - startedAt.current;
      clear();
    }
    setPaused(true);
  };
  const handleResume = () => {
    if (!open) return;
    if (reducedMotion) start(Math.max(0, remaining.current));
    setPaused(false);
  };

  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={{ type: "spring", duration: 0.35, bounce: 0 }}
          onMouseEnter={handlePause}
          onMouseLeave={handleResume}
          onFocus={handlePause}
          onBlur={handleResume}
          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={() => {
              clear();
              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"
          >
            <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={() => onCommit?.()}
                  style={
                    reducedMotion
                      ? undefined
                      : {
                          animation: `paragon-undo-toast-deplete ${duration}ms linear forwards`,
                          animationPlayState: paused ? "paused" : "running",
                        }
                  }
                />
              </svg>
            </span>
            {undoLabel}
          </button>
        </motion.div>
      )}
    </AnimatePresence>
  );
}