Controls
Timed undo
A delete button that counts down a few seconds and can be undone before anything happens.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
timed-undo-demo.tsx
import { TimedUndo } from '@/components/timed-undo'
export default function TimedUndoDemo() {
return (
<div className="flex justify-center">
<TimedUndo label="Delete order" seconds={5} onConfirm={() => console.log('deleted')} />
</div>
)
}Source
timed-undo.tsx
// Based on Watermelon UI's “Timed undo action” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten so the action actually runs when the time is up.
import { Undo2 } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
export type TimedUndoProps = {
/** Seconds the person has to change their mind. */
seconds?: number
label?: string
undoLabel?: string
/** Runs when the countdown reaches zero without an undo. */
onConfirm: () => void
onUndo?: () => void
className?: string
}
/** A destructive button that waits a few seconds and can be undone in the meantime. */
export function TimedUndo({ seconds = 5, label = 'Delete', undoLabel = 'Undo', onConfirm, onUndo, className }: TimedUndoProps) {
const [left, setLeft] = useState<number | null>(null)
const confirm = useRef(onConfirm)
useEffect(() => {
confirm.current = onConfirm
})
const waiting = left !== null
useEffect(() => {
if (left === null) return
const t = setTimeout(() => {
if (left > 1) return setLeft(left - 1)
setLeft(null)
confirm.current()
}, 1000)
return () => clearTimeout(t)
}, [left])
const toggle = () => {
if (waiting) {
setLeft(null)
onUndo?.()
} else setLeft(seconds)
}
const fade = { initial: { opacity: 0, filter: 'blur(2px)' }, animate: { opacity: 1, filter: 'blur(0px)' }, exit: { opacity: 0, filter: 'blur(2px)' } }
return (
<MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 260, damping: 32 }}>
<motion.button
layout
type="button"
onClick={toggle}
style={{ borderRadius: 14 }}
className={cn(
'inline-flex min-h-11 items-center gap-2 overflow-hidden px-1.5 font-medium transition-colors duration-300',
waiting ? 'bg-destructive/10 text-destructive' : 'bg-destructive px-5 text-white',
className,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{waiting && (
<motion.span key="icon" {...fade} className="grid size-8 place-items-center rounded-[10px] bg-destructive text-white">
<Undo2 className="size-4" aria-hidden />
</motion.span>
)}
<motion.span key={waiting ? 'undo' : 'label'} layout="position" {...fade} className="px-1">
{waiting ? undoLabel : label}
</motion.span>
{waiting && (
<motion.span key="count" {...fade} className="grid h-8 min-w-8 place-items-center overflow-hidden rounded-[10px] bg-destructive px-2 text-white tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={left}
initial={{ y: -14, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 14, opacity: 0 }}
aria-hidden
>
{left}
</motion.span>
</AnimatePresence>
</motion.span>
)}
</AnimatePresence>
{/* Said once when the countdown starts, not every second. */}
<span className="sr-only" aria-live="polite">
{waiting ? `${label} in ${seconds} seconds. Press again to undo.` : ''}
</span>
</motion.button>
</MotionConfig>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/timed-undo.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/timed-undo