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.