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.
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 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>
);
}