Mikkel Manniche
Dansk English Deutsch

Bedienelemente

Timed undo

Eine Löschtaste, die ein paar Sekunden herunterzählt und sich vorher rückgängig machen lässt.

Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.

Vorschau

Verwendung

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>
  )
}

Quellcode

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>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/timed-undo.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/timed-undo

Dateien

  • timed-undo.tsx