Mikkel Manniche
Dansk English Deutsch

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.json

With the registry added (components.json)

npx shadcn@latest add @manniche/timed-undo

Files

  • timed-undo.tsx