UI Library

Undo toast

A toast carrying a real action and a countdown — the pattern that replaces a confirmation dialog for reversible deletes. The timer pauses on hover and focus, so it cannot expire while someone is reaching for Undo.

Feedbacktoastundotimer
Preview
Task deleted
Dismissing in 8s
apps/site/blocks/components/ToastAction.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { Trash2, Undo2, X } from 'lucide-react';

export function ToastAction({
  title = 'Task deleted',
  actionLabel = 'Undo',
  /** Seconds before the toast auto-dismisses. */
  duration = 8,
  onAction,
  onDismiss,
}: {
  title?: string;
  actionLabel?: string;
  duration?: number;
  onAction?: () => void;
  onDismiss?: () => void;
}) {
  const [remaining, setRemaining] = useState(duration);
  const [paused, setPaused] = useState(false);
  const [open, setOpen] = useState(true);
  // Latest-ref pattern: the countdown effect reads the newest callback without
  // taking it as a dependency, which would restart the timer every time the
  // parent re-renders with a fresh closure. Synced in an effect, never during
  // render — a render-phase write is a React 19 lint error, and for good reason:
  // it makes the value depend on render timing.
  const onDismissRef = useRef(onDismiss);
  useEffect(() => {
    onDismissRef.current = onDismiss;
  }, [onDismiss]);

  useEffect(() => {
    if (paused || !open) return;
    const id = setInterval(() => {
      setRemaining((r) => {
        if (r <= 1) {
          clearInterval(id);
          setOpen(false);
          onDismissRef.current?.();
          return 0;
        }
        return r - 1;
      });
    }, 1000);
    return () => clearInterval(id);
  }, [paused, open]);

  if (!open) {
    return (
      <button
        type="button"
        onClick={() => {
          setRemaining(duration);
          setOpen(true);
        }}
        className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm font-medium transition-colors hover:bg-accent"
      >
        <Trash2 className="h-4 w-4" />
        Delete a task
      </button>
    );
  }

  return (
    <div
      role="status"
      // Pause on hover AND on focus — a keyboard user tabbing toward Undo gets
      // the same grace a mouse user gets, which is the whole point of the pattern.
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={() => setPaused(false)}
      className="flex w-full max-w-sm items-center gap-3 overflow-hidden rounded-lg border border-border bg-card p-3 shadow-lg"
    >
      <div className="min-w-0 flex-1">
        <div className="text-sm font-medium leading-tight">{title}</div>
        <div className="mt-0.5 text-xs text-muted-foreground">
          {paused ? 'Paused' : `Dismissing in ${remaining}s`}
        </div>
      </div>

      <button
        type="button"
        onClick={() => {
          setOpen(false);
          onAction?.();
        }}
        className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-xs font-medium transition-colors hover:bg-accent"
      >
        <Undo2 className="h-3.5 w-3.5" />
        {actionLabel}
      </button>

      <button
        type="button"
        onClick={() => {
          setOpen(false);
          onDismiss?.();
        }}
        aria-label="Dismiss"
        className="-m-1 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
      >
        <X className="h-3.5 w-3.5" />
      </button>
    </div>
  );
}

Dependencies

  • lucide-react — npm package

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.