# Toast Undo Progress — React Notification Component

> An undo toast with a draining countdown bar that pauses on hover; undo springs the removed row back into the list, and letting it run out commits the action.

- Category: [Notifications](https://pixelpapa.com/components/notifications.md)
- Tags: toast, undo, countdown, progress, archive
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/notifications/toast-undo-progress

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/toast-undo-progress.json"
```

The file is written to `components/pixelpapa/toast-undo-progress.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Archive, Undo2 } from "lucide-react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type UndoToastProps = {
  /** Change this to restart the countdown for a new action. */
  actionKey: string | number;
  message: ReactNode;
  /** Milliseconds before the action is committed. */
  duration?: number;
  onUndo: () => void;
  onCommit: () => void;
  className?: string;
};

export function UndoToast({ actionKey, message, duration = 6000, onUndo, onCommit, className }: UndoToastProps) {
  const reduce = useReducedMotion();
  const progress = useMotionValue(1);
  const seconds = useTransform(progress, (p) => Math.ceil((p * duration) / 1000));
  const controls = useRef<ReturnType<typeof animate> | null>(null);
  const commit = useRef(onCommit);
  useEffect(() => {
    commit.current = onCommit;
  });

  // One linear animation drives both the bar and the seconds label; hover pauses it in place.
  useEffect(() => {
    progress.set(1);
    controls.current = animate(progress, 0, { duration: duration / 1000, ease: "linear", onComplete: () => commit.current() });
    return () => controls.current?.stop();
  }, [actionKey, duration, progress]);

  return (
    <motion.div
      role="status"
      aria-live="polite"
      initial={{ opacity: 0, y: reduce ? 0 : 40, scale: 0.92 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      exit={{ opacity: 0, y: reduce ? 0 : 24, scale: 0.95, transition: { duration: 0.16 } }}
      transition={{ type: "spring", stiffness: 420, damping: 30 }}
      onPointerEnter={() => controls.current?.pause()}
      onPointerLeave={() => controls.current?.play()}
      onFocus={() => controls.current?.pause()}
      onBlur={() => controls.current?.play()}
      className={cn("relative flex w-[340px] max-w-full items-center gap-3 overflow-hidden rounded-2xl bg-foreground py-3 pr-3 pl-4 text-background shadow-xl shadow-black/20", className)}
    >
      <span className="min-w-0 flex-1 truncate text-sm">{message}</span>
      <motion.span className="w-5 text-right text-xs tabular-nums opacity-60">{seconds}</motion.span>
      <button
        onClick={onUndo}
        className="flex h-8 items-center gap-1.5 rounded-lg bg-background/15 px-3 text-sm font-medium transition-colors hover:bg-background/25 focus-visible:outline-background"
      >
        <Undo2 className="size-3.5" /> Undo
      </button>
      <motion.span aria-hidden style={{ scaleX: progress }} className="absolute inset-x-0 bottom-0 h-[3px] origin-left bg-primary" />
    </motion.div>
  );
}

const inbox = [
  { id: "a", from: "Harbour Coffee", subject: "Your November invoice is ready", tone: "bg-chart-4/30" },
  { id: "b", from: "Mei Tanaka", subject: "Notes from the pricing review", tone: "bg-chart-2/30" },
  { id: "c", from: "Loomwork", subject: "Sync engine 3.2 release notes", tone: "bg-chart-3/30" },
  { id: "d", from: "Tobias Lindqvist", subject: "Can you approve the Q4 budget?", tone: "bg-chart-5/30" },
];

export default function ToastUndoProgressDemo() {
  const [archived, setArchived] = useState<string[]>(["b"]);
  const [pending, setPending] = useState<string | null>("b");
  const rows = inbox.filter((m) => !archived.includes(m.id));
  const pendingMail = inbox.find((m) => m.id === pending);

  return (
    <div className="relative h-[420px] w-full max-w-md overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
      <div className="flex items-center justify-between border-b border-border px-5 py-3.5">
        <p className="font-semibold tracking-tight text-card-foreground">Inbox</p>
        <button
          onClick={() => {
            setArchived([]);
            setPending(null);
          }}
          className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
        >
          Reset
        </button>
      </div>
      <ul>
        <AnimatePresence initial={false} mode="popLayout">
          {rows.map((mail) => (
            <motion.li
              layout
              key={mail.id}
              initial={{ opacity: 0, scale: 0.94 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, x: -60, transition: { duration: 0.2 } }}
              transition={{ type: "spring", stiffness: 420, damping: 34 }}
              className="group flex items-center gap-3 border-b border-border px-5 py-3"
            >
              <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-foreground", mail.tone)}>{mail.from[0]}</span>
              <span className="min-w-0 flex-1">
                <span className="block text-sm font-medium text-card-foreground">{mail.from}</span>
                <span className="block truncate text-xs text-muted-foreground">{mail.subject}</span>
              </span>
              <button
                aria-label={`Archive message from ${mail.from}`}
                onClick={() => {
                  setArchived([...archived, mail.id]);
                  setPending(mail.id);
                }}
                className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
              >
                <Archive className="size-4" />
              </button>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
      {!rows.length && <p className="pt-16 text-center text-sm text-muted-foreground">Inbox zero. Nicely done.</p>}

      <div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
        <AnimatePresence mode="wait">
          {pendingMail && (
            <UndoToast
              key={pendingMail.id}
              actionKey={pendingMail.id}
              message={
                <>
                  Archived <span className="font-medium">“{pendingMail.subject}”</span>
                </>
              }
              onUndo={() => {
                setArchived(archived.filter((id) => id !== pendingMail.id));
                setPending(null);
              }}
              onCommit={() => setPending(null)}
              className="pointer-events-auto"
            />
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Toast Undo Progress" component from PixelPapa UI to this project.

What it is: An undo toast with a draining countdown bar that pauses on hover; undo springs the removed row back into the list, and letting it run out commits the action.
Category: Notifications. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/toast-undo-progress.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/toast-undo-progress.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/toast-undo-progress" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More notifications

- [Alert Inline Variants](https://pixelpapa.com/components/notifications/alert-inline-variants.md): Inline alerts in info, success, warning and error tones with an optional action; dismissing one blurs it out and the stack closes the gap on a spring.
- [Notification Dynamic Island](https://pixelpapa.com/components/notifications/notification-dynamic-island.md) (Pro): A pill that springs between sizes to hold live activities (an incoming call, a timer, now playing) with content that blurs across each morph.
- [Notification Inbox Popover](https://pixelpapa.com/components/notifications/notification-inbox-popover.md) (Pro): A bell popover with Unread and All tabs: marking a row read drains its dot and folds it out of the unread list while the badge count rolls down.
- [Notification List Swipe](https://pixelpapa.com/components/notifications/notification-list-swipe.md): Notification rows you can swipe: right to archive, left to delete. The action underneath grows with the drag, and a flick or a long pull sends the row flying before the list closes up.
- [Toast Stack Sonner Like](https://pixelpapa.com/components/notifications/toast-stack-sonner-like.md) (Pro): A hand-built toaster: toasts pile into a tight stack, fan out into a readable list on hover, pause their timers while you look, and promise toasts morph from loading to done.
