{
  "name": "save-button",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Save Button\n * @version: 1.0.0\n * @date: 2026-10-21\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Check, Plus } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type FormEvent,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** How long the \"Saved to\" strip stays up when left alone. */\nconst STRIP_FOR = 4000;\n\nexport interface SaveCollection {\n  id: string;\n  name: string;\n  /** Items already in it, before this one. */\n  count: number;\n  /** Tailwind background class for its swatch. */\n  color: string;\n}\n\nexport interface SaveButtonProps {\n  collections?: SaveCollection[];\n  /** Where a tap saves to, by id. Defaults to the first collection. */\n  defaultCollection?: string;\n  defaultSaved?: boolean;\n  onSave?: (collectionId: string) => void;\n  onMove?: (collectionId: string) => void;\n  onRemove?: () => void;\n  className?: string;\n}\n\nconst DEFAULT_COLLECTIONS: SaveCollection[] = [\n  { id: \"weekend\", name: \"Weekend trips\", count: 12, color: \"bg-orange-400\" },\n  { id: \"tokyo\", name: \"Tokyo 2027\", count: 31, color: \"bg-sky-400\" },\n  { id: \"food\", name: \"Places to eat\", count: 48, color: \"bg-emerald-400\" },\n  { id: \"someday\", name: \"Someday\", count: 7, color: \"bg-violet-400\" },\n];\n\nconst SWATCHES = [\n  \"bg-rose-400\",\n  \"bg-amber-300\",\n  \"bg-teal-400\",\n  \"bg-indigo-400\",\n];\n\ntype View = \"closed\" | \"saved\" | \"picking\" | \"removed\";\n\n// The panel sits under AnimatePresence, so it's found by query, not ref.\nconst findPanel = (root: HTMLElement | null) =>\n  root?.querySelector(\"[data-save-panel]\") ?? null;\n\nconst focusInPanel = (root: HTMLElement | null) =>\n  Boolean(findPanel(root)?.contains(document.activeElement));\n\n// Whatever closes or swaps the panel's content would drop focus on <body>,\n// so it goes back to the button first.\nconst returnFocus = (root: HTMLElement | null, button: HTMLElement | null) => {\n  if (focusInPanel(root)) {\n    button?.focus();\n  }\n};\n\n/** A bookmark whose ink drops in from the top like a ribbon when saved. */\nfunction Ribbon({\n  saved,\n  reduceMotion,\n}: {\n  saved: boolean;\n  reduceMotion: boolean;\n}) {\n  const clip = useId();\n  let ribbonTransition: object = { duration: 0.18, ease: EASE_OUT };\n  if (reduceMotion) {\n    ribbonTransition = { duration: 0 };\n  } else if (saved) {\n    ribbonTransition = { type: \"spring\", stiffness: 520, damping: 24 };\n  }\n  return (\n    <svg\n      aria-hidden\n      className=\"size-4 overflow-visible\"\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n    >\n      <title>Bookmark</title>\n      <defs>\n        <clipPath id={clip}>\n          <path d=\"M3.5 2.5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v11.2l-4.5-3-4.5 3z\" />\n        </clipPath>\n      </defs>\n      <g clipPath={`url(#${clip})`}>\n        <motion.rect\n          animate={{ y: saved ? 0 : -16 }}\n          className=\"fill-current\"\n          height=\"16\"\n          initial={false}\n          transition={ribbonTransition}\n          width=\"16\"\n          x=\"0\"\n          y=\"0\"\n        />\n      </g>\n      <path\n        className=\"stroke-current\"\n        d=\"M3.5 2.5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v11.2l-4.5-3-4.5 3z\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n      />\n    </svg>\n  );\n}\n\nexport default function SaveButton({\n  collections: initialCollections = DEFAULT_COLLECTIONS,\n  defaultCollection,\n  defaultSaved = false,\n  onSave,\n  onMove,\n  onRemove,\n  className,\n}: SaveButtonProps) {\n  const [collections, setCollections] = useState(initialCollections);\n  const [savedTo, setSavedTo] = useState<string | null>(\n    defaultSaved\n      ? (defaultCollection ?? initialCollections[0]?.id ?? null)\n      : null\n  );\n  const [lastUsed, setLastUsed] = useState(\n    defaultCollection ?? initialCollections[0]?.id ?? \"\"\n  );\n  const [view, setView] = useState<View>(\"closed\");\n  const [draft, setDraft] = useState(\"\");\n  const [moved, setMoved] = useState(false);\n  const reduceMotion = Boolean(useReducedMotion());\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const hoverRef = useRef(false);\n  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  const contentRef = useRef<HTMLDivElement>(null);\n  const isOpen = view !== \"closed\";\n  const [contentHeight, setContentHeight] = useState<number>();\n\n  // The strip grows into the picker and back by its measured height.\n  useLayoutEffect(() => {\n    const content = contentRef.current;\n    if (!(isOpen && content)) {\n      setContentHeight(undefined);\n      return;\n    }\n    const observer = new ResizeObserver(() =>\n      setContentHeight(content.offsetHeight)\n    );\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [isOpen]);\n\n  const saved = savedTo !== null;\n  const current = collections.find((c) => c.id === savedTo);\n\n  // The strip closes on its own unless it is being read or used.\n  const schedule = (ms = STRIP_FOR) => {\n    clearTimeout(timerRef.current);\n    timerRef.current = setTimeout(() => {\n      if (!(hoverRef.current || focusInPanel(rootRef.current))) {\n        setView((v) => (v === \"picking\" ? v : \"closed\"));\n      }\n    }, ms);\n  };\n\n  useEffect(() => () => clearTimeout(timerRef.current), []);\n\n  useEffect(() => {\n    if (view === \"closed\") {\n      return;\n    }\n    const onPointer = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) {\n        setView(\"closed\");\n      }\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        returnFocus(rootRef.current, buttonRef.current);\n        setView(\"closed\");\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointer);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointer);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [view]);\n\n  // Opening the picker moves focus onto the collection it's saved in.\n  useEffect(() => {\n    if (view === \"picking\") {\n      findPanel(rootRef.current)\n        ?.querySelector<HTMLButtonElement>('[aria-current=\"true\"]')\n        ?.focus();\n    }\n  }, [view]);\n\n  const toggle = () => {\n    if (saved) {\n      setSavedTo(null);\n      setView(\"removed\");\n      onRemove?.();\n      schedule(1600);\n      return;\n    }\n    setSavedTo(lastUsed);\n    setMoved(false);\n    setView(\"saved\");\n    onSave?.(lastUsed);\n    schedule();\n  };\n\n  const moveTo = (id: string) => {\n    returnFocus(rootRef.current, buttonRef.current);\n    setSavedTo(id);\n    setLastUsed(id);\n    setMoved(id !== savedTo);\n    setView(\"saved\");\n    if (id !== savedTo) {\n      onMove?.(id);\n    }\n    schedule();\n  };\n\n  const create = (event: FormEvent) => {\n    event.preventDefault();\n    const name = draft.trim();\n    if (!name) {\n      return;\n    }\n    const id = `${name.toLowerCase().replace(/\\s+/g, \"-\")}-${collections.length}`;\n    const color = SWATCHES[collections.length % SWATCHES.length];\n    setCollections((list) => [...list, { id, name, count: 0, color }]);\n    setDraft(\"\");\n    moveTo(id);\n  };\n\n  const countFor = (collection: SaveCollection) =>\n    collection.count + (savedTo === collection.id ? 1 : 0);\n\n  const swap = reduceMotion\n    ? { duration: 0 }\n    : { duration: 0.2, ease: EASE_OUT };\n\n  return (\n    <div\n      className={cn(\"relative inline-flex\", className)}\n      onPointerEnter={() => {\n        hoverRef.current = true;\n      }}\n      onPointerLeave={() => {\n        hoverRef.current = false;\n        if (view === \"saved\" || view === \"removed\") {\n          schedule(1800);\n        }\n      }}\n      ref={rootRef}\n    >\n      <button\n        aria-pressed={saved}\n        className={cn(\n          \"inline-flex h-10 items-center gap-2 rounded-full border pr-4 pl-3.5 font-medium text-sm outline-none\",\n          \"transition-colors duration-150 ease-out\",\n          \"focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n          saved\n            ? \"border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800 dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white\"\n            : \"border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-900\"\n        )}\n        onClick={toggle}\n        ref={buttonRef}\n        type=\"button\"\n      >\n        <Ribbon reduceMotion={reduceMotion} saved={saved} />\n        <span className=\"relative inline-flex overflow-hidden\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              animate={{ y: 0, opacity: 1 }}\n              exit={{ y: saved ? -14 : 14, opacity: 0 }}\n              initial={{ y: saved ? 14 : -14, opacity: 0 }}\n              key={saved ? \"saved\" : \"save\"}\n              transition={swap}\n            >\n              {saved ? \"Saved\" : \"Save\"}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n\n      <AnimatePresence>\n        {view !== \"closed\" && (\n          <motion.div\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            className=\"absolute top-full left-1/2 z-20 mt-2 w-72 origin-top -translate-x-1/2 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_8px_24px_-8px_rgb(0_0_0/0.18)] dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-[0_8px_24px_-8px_rgb(0_0_0/0.6)]\"\n            data-save-panel\n            exit={{\n              opacity: 0,\n              y: -4,\n              scale: 0.98,\n              transition: { duration: 0.12 },\n            }}\n            initial={{ opacity: 0, y: -6, scale: 0.97 }}\n            transition={\n              reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }\n            }\n          >\n            <motion.div\n              animate={{ height: contentHeight ?? \"auto\" }}\n              initial={false}\n              transition={\n                reduceMotion\n                  ? { duration: 0 }\n                  : { duration: 0.26, ease: EASE_OUT }\n              }\n            >\n              <div ref={contentRef}>\n                {view === \"picking\" ? (\n                  <Picker\n                    collections={collections}\n                    countFor={countFor}\n                    create={create}\n                    draft={draft}\n                    moveTo={moveTo}\n                    savedTo={savedTo}\n                    setDraft={setDraft}\n                  />\n                ) : (\n                  <div\n                    aria-live=\"polite\"\n                    className=\"flex h-12 items-center gap-2.5 pr-2 pl-3 text-sm\"\n                  >\n                    {view === \"removed\" || !current ? (\n                      <span className=\"text-zinc-500 dark:text-zinc-400\">\n                        Removed from saved\n                      </span>\n                    ) : (\n                      <>\n                        <span\n                          className={cn(\n                            \"size-5 shrink-0 rounded-md\",\n                            current.color\n                          )}\n                        />\n                        <span className=\"min-w-0 flex-1 truncate text-zinc-500 dark:text-zinc-400\">\n                          {moved ? \"Moved to \" : \"Saved to \"}\n                          <span className=\"relative inline-flex align-bottom\">\n                            <AnimatePresence initial={false} mode=\"popLayout\">\n                              <motion.span\n                                animate={{ y: 0, opacity: 1 }}\n                                className=\"font-medium text-zinc-900 dark:text-zinc-100\"\n                                exit={{ y: -10, opacity: 0 }}\n                                initial={{ y: 10, opacity: 0 }}\n                                key={current.id}\n                                transition={swap}\n                              >\n                                {current.name}\n                              </motion.span>\n                            </AnimatePresence>\n                          </span>\n                        </span>\n                        <button\n                          className=\"h-8 shrink-0 rounded-full px-3 font-medium text-xs text-zinc-900 transition-colors duration-150 hover:bg-zinc-100 dark:text-zinc-100 dark:hover:bg-zinc-900\"\n                          onClick={() => {\n                            clearTimeout(timerRef.current);\n                            setView(\"picking\");\n                          }}\n                          type=\"button\"\n                        >\n                          Change\n                        </button>\n                      </>\n                    )}\n                  </div>\n                )}\n              </div>\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Picker({\n  collections,\n  savedTo,\n  countFor,\n  moveTo,\n  draft,\n  setDraft,\n  create,\n}: {\n  collections: SaveCollection[];\n  savedTo: string | null;\n  countFor: (collection: SaveCollection) => number;\n  moveTo: (id: string) => void;\n  draft: string;\n  setDraft: (value: string) => void;\n  create: (event: FormEvent) => void;\n}) {\n  return (\n    <div className=\"p-1.5\">\n      <p className=\"px-2 pt-1.5 pb-1 text-[11px] text-zinc-400 dark:text-zinc-500\">\n        Save to\n      </p>\n      <ul className=\"max-h-56 overflow-y-auto\">\n        {collections.map((collection) => {\n          const active = collection.id === savedTo;\n          return (\n            <li key={collection.id}>\n              <button\n                aria-current={active}\n                className=\"flex h-11 w-full items-center gap-2.5 rounded-xl px-2 text-left text-sm outline-none transition-colors duration-150 hover:bg-zinc-100 focus-visible:bg-zinc-100 dark:focus-visible:bg-zinc-900 dark:hover:bg-zinc-900\"\n                onClick={() => moveTo(collection.id)}\n                type=\"button\"\n              >\n                <span\n                  className={cn(\"size-7 shrink-0 rounded-lg\", collection.color)}\n                />\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate text-zinc-900 dark:text-zinc-100\">\n                    {collection.name}\n                  </span>\n                  <span className=\"block text-[11px] text-zinc-400 tabular-nums dark:text-zinc-500\">\n                    {countFor(collection)} saved\n                  </span>\n                </span>\n                {active && (\n                  <Check\n                    aria-hidden\n                    className=\"size-4 text-zinc-900 dark:text-zinc-100\"\n                    strokeWidth={2.5}\n                  />\n                )}\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n      <form\n        className=\"mt-1 flex h-11 items-center gap-2.5 rounded-xl border-zinc-100 border-t px-2 pt-1 dark:border-zinc-900\"\n        onSubmit={create}\n      >\n        <span className=\"flex size-7 shrink-0 items-center justify-center rounded-lg border border-zinc-300 border-dashed text-zinc-400 dark:border-zinc-700\">\n          <Plus aria-hidden className=\"size-3.5\" />\n        </span>\n        <input\n          aria-label=\"New collection\"\n          className=\"min-w-0 flex-1 bg-transparent text-sm text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500\"\n          onChange={(event) => setDraft(event.target.value)}\n          placeholder=\"New collection\"\n          value={draft}\n        />\n        <AnimatePresence initial={false}>\n          {draft.trim() && (\n            <motion.button\n              animate={{ opacity: 1, x: 0 }}\n              className=\"h-7 shrink-0 rounded-full bg-zinc-900 px-2.5 font-medium text-[11px] text-white dark:bg-zinc-100 dark:text-zinc-900\"\n              exit={{ opacity: 0, x: 4 }}\n              initial={{ opacity: 0, x: 4 }}\n              transition={{ duration: 0.14, ease: EASE_OUT }}\n              type=\"submit\"\n            >\n              Create\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </form>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/save-button.tsx",
      "target": "components/kokonutui/save-button.tsx"
    }
  ]
}