{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "undo-pill",
  "title": "Undo Pill",
  "description": "A pill that rises after a destructive action with a draining countdown ring. Undo (or ⌘Z) reverts it; only expiry commits it. Hover or focus pauses the countdown.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/undo-pill/undo-pill.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, animate, motion, useMotionValue } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { isEditable } from \"../lib/dom\";\nimport { enter, exit, reducedFade, smooth, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../lib/motion\";\nimport { useIsMac } from \"../lib/platform\";\nimport { press } from \"../lib/press\";\n\nexport type UndoPillLabels = {\n  /** The button label. Default \"Undo\". */\n  undo: string;\n  /** The shortcut hint beside it. Default \"⌘ Z\" on Apple platforms, \"Ctrl Z\" elsewhere. */\n  shortcut: (mac: boolean) => string;\n};\n\nexport type UndoPillProps = {\n  /** Show the pill. Keep it mounted and toggle this so the exit animates. */\n  open: boolean;\n  /** What happened, e.g. `Deleted \"Q3 roadmap.pdf\"`. Changing it restarts the countdown. */\n  message: string;\n  onUndo: () => void;\n  /** Called when the countdown runs out: commit the destructive action here. */\n  onExpire: () => void;\n  /** Countdown length in ms. Changing it while open restarts the countdown. */\n  duration?: number;\n  /** Bind ⌘Z / Ctrl+Z to undo while the pill is open. Ignored while typing in a field. */\n  shortcut?: boolean;\n  /** Pause the countdown while the pointer is over the pill. Default true. */\n  pauseOnHover?: boolean;\n  /** Pause the countdown while focus is inside the pill. Default true. */\n  pauseOnFocus?: boolean;\n  /** Called when hover or focus pauses the countdown, and when it resumes. */\n  onPause?: () => void;\n  onResume?: () => void;\n  /** Where the pill sits in its positioned parent: centred at the bottom (default) or top, or \"none\" to place it yourself. */\n  placement?: \"bottom\" | \"top\" | \"none\";\n  labels?: Partial<UndoPillLabels>;\n  /** Appended to the pill's classes; placement is kept. */\n  className?: string;\n};\n\nconst RING = 2 * Math.PI * 7; // circumference of the r=7 countdown ring\n\nconst PLACEMENT = {\n  bottom: \"absolute bottom-4 left-1/2 -translate-x-1/2\",\n  top: \"absolute top-4 left-1/2 -translate-x-1/2\",\n  none: \"\",\n};\n\nconst DEFAULT_LABELS: UndoPillLabels = { undo: \"Undo\", shortcut: (mac) => (mac ? \"⌘ Z\" : \"Ctrl Z\") };\n\n/**\n * The never-act-immediately pattern: a destructive action shows this pill with\n * a draining ring; Undo reverts it, and only expiry commits it.\n */\nexport function UndoPill({\n  open,\n  message,\n  onUndo,\n  onExpire,\n  duration = 5000,\n  shortcut = true,\n  pauseOnHover = true,\n  pauseOnFocus = true,\n  onPause,\n  onResume,\n  placement = \"bottom\",\n  labels,\n  className = \"\",\n}: UndoPillProps) {\n  const reduce = useSafeReducedMotion();\n  const mac = useIsMac();\n  const text = { ...DEFAULT_LABELS, ...labels };\n\n  // Hover and focus are held apart. Clicking Undo removes the pill under the pointer, so\n  // neither leave nor blur fires: both reset whenever the pill opens, or it would reopen paused.\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) {\n      setHovered(false);\n      setFocused(false);\n    }\n  }\n  const paused = open && ((pauseOnHover && hovered) || (pauseOnFocus && focused));\n\n  const remaining = useRef(duration);\n  const startedAt = useRef(0);\n  const drained = useMotionValue(0);\n  const callbacks = useRef({ onExpire, onPause, onResume });\n  useEffect(() => {\n    callbacks.current = { onExpire, onPause, onResume };\n  });\n\n  // Opening, a new message (a new action) or a new duration starts the countdown over.\n  useEffect(() => {\n    if (!open) return;\n    remaining.current = duration;\n    drained.jump(0);\n  }, [open, message, duration, drained]);\n\n  // The timer owns expiry, and the ring is driven from the same clock in JS, so neither\n  // reduced motion nor a global animation clamp can drain it early.\n  useEffect(() => {\n    if (!open || paused) return;\n    startedAt.current = performance.now();\n    const t = window.setTimeout(() => callbacks.current.onExpire(), remaining.current);\n    const ring = animate(drained, RING, { duration: Math.max(0, remaining.current) / 1000, ease: \"linear\" });\n    return () => {\n      window.clearTimeout(t);\n      ring.stop();\n      remaining.current -= performance.now() - startedAt.current;\n    };\n  }, [open, paused, message, duration, drained]);\n\n  const wasPaused = useRef(paused);\n  useEffect(() => {\n    if (wasPaused.current === paused) return;\n    wasPaused.current = paused;\n    if (paused) callbacks.current.onPause?.();\n    else if (open) callbacks.current.onResume?.();\n  }, [paused, open]);\n\n  const undo = useCallback(() => {\n    if (open) onUndo();\n  }, [open, onUndo]);\n\n  useEffect(() => {\n    if (!open || !shortcut) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (isEditable(e.target)) return;\n      if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === \"z\") {\n        e.preventDefault();\n        undo();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open, shortcut, undo]);\n\n  const rise = placement === \"top\" ? -1 : 1;\n\n  return (\n    <>\n      {/* A live region must exist before its text changes, so it stays mounted outside the pill. */}\n      <span role=\"status\" className=\"sr-only\">\n        {open ? message : \"\"}\n      </span>\n      <AnimatePresence>\n        {open && (\n          <motion.div\n            layout\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 * rise, scale: 0.97, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: reducedFade }\n                : { opacity: 0, y: 6 * rise, scale: 0.98, filter: \"blur(4px)\", transition: exit }\n            }\n            transition={reduce ? reducedFade : { ...smooth, opacity: enter, filter: enter }}\n            onPointerEnter={() => setHovered(true)}\n            onPointerLeave={() => setHovered(false)}\n            onFocus={() => setFocused(true)}\n            onBlur={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false);\n            }}\n            className={`z-10 flex h-11 max-w-[calc(100%-2rem)] items-center gap-3 rounded-pill border border-line bg-surface-raised pr-1.5 pl-3 text-ui shadow-overlay ${PLACEMENT[placement]} ${className}`}\n          >\n            <svg viewBox=\"0 0 18 18\" className=\"size-[18px] shrink-0 -rotate-90\" aria-hidden>\n              <circle cx=\"9\" cy=\"9\" r=\"7\" fill=\"none\" strokeWidth=\"2\" className=\"stroke-line\" />\n              <motion.circle\n                cx=\"9\"\n                cy=\"9\"\n                r=\"7\"\n                fill=\"none\"\n                strokeWidth=\"2\"\n                strokeLinecap=\"round\"\n                strokeDasharray={RING}\n                className=\"stroke-text-secondary\"\n                style={{ strokeDashoffset: drained }}\n              />\n            </svg>\n\n            <motion.span layout=\"position\" className=\"relative min-w-0 overflow-hidden whitespace-nowrap text-text\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={message}\n                  className=\"block truncate\"\n                  initial={reduce ? { opacity: 0 } : swapIn}\n                  animate={swapShown}\n                  exit={reduce ? { opacity: 0 } : swapOut}\n                  transition={reduce ? reducedFade : swap}\n                >\n                  {message}\n                </motion.span>\n              </AnimatePresence>\n            </motion.span>\n\n            <motion.button\n              layout=\"position\"\n              type=\"button\"\n              onClick={undo}\n              className={`inline-flex h-control-sm shrink-0 cursor-pointer items-center gap-1.5 rounded-pill bg-surface-hover px-3 text-ui-sm font-medium text-text hover-fine:bg-surface-active ${press}`}\n            >\n              {text.undo}\n              {shortcut && <kbd className=\"font-sans text-ui-xs text-text-muted\">{text.shortcut(mac)}</kbd>}\n            </motion.button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </>\n  );\n}\n\nexport type UndoQueueOptions<T> = {\n  /** The pill's text for the pending items, e.g. `items.length === 1 ? \"Deleted …\" : \"Deleted 3 files\"`. */\n  message: (items: T[]) => string;\n  /** Put the items back. */\n  onUndo?: (items: T[]) => void;\n  /** The countdown ran out: make the action permanent. */\n  onCommit?: (items: T[]) => void;\n};\n\n/**\n * The state machine every Undo Pill needs: actions taken while the pill is open join one\n * batch, Undo restores the whole batch, expiry commits it. Spread `pill` onto `<UndoPill>`.\n */\nexport function useUndoQueue<T>({ message, onUndo, onCommit }: UndoQueueOptions<T>) {\n  const [pending, setPending] = useState<T[]>([]);\n  const push = useCallback((item: T) => setPending((p) => [...p, item]), []);\n  const undo = () => {\n    onUndo?.(pending);\n    setPending([]);\n  };\n  const commit = () => {\n    onCommit?.(pending);\n    setPending([]);\n  };\n  return {\n    pending,\n    push,\n    undo,\n    commit,\n    pill: { open: pending.length > 0, message: pending.length > 0 ? message(pending) : \"\", onUndo: undo, onExpire: commit },\n  };\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/undo-pill/undo-pill.tsx"
    }
  ],
  "docs": "Import UndoPill from components/eased/undo-pill/undo-pill. The Eased tokens were merged into your global CSS; nothing to import. useUndoQueue() handles batching, undo and commit. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}