A commit-on-timeout toast: the action fires only when the depleting ring finishes, Undo cancels it, and hover pauses the countdown.
npx shadcn@latest add @paragon/undo-toast"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>
);
}