{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "add-to-cart",
  "type": "registry:ui",
  "title": "Add to Cart",
  "description": "A button that morphs into a quantity stepper, with a cart badge that bumps.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/quantity-stepper.json"
  ],
  "files": [
    {
      "path": "components/ui/add-to-cart.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { QuantityStepper } from \"@/components/ui/quantity-stepper\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, Cart, Loader } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Phase = \"idle\" | \"busy\" | \"failed\";\n\nexport type AddToCartProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** Units of this item in the cart. 0 shows the button; 1 or more shows the stepper. */\n  quantity?: number;\n  defaultQuantity?: number;\n  onQuantityChange?: (quantity: number) => void;\n  /**\n   * Runs on the first press. Return a promise to show the busy state; the button turns into the\n   * stepper when it resolves and says it couldn't add when it rejects.\n   */\n  onAdd?: () => void | Promise<unknown>;\n  /** Units in stock. 0 shows the sold-out state. */\n  max?: number;\n  /** What the stepper's hint says at max. */\n  stockMessage?: (max: number) => string;\n  /** Used in accessible names: \"Quantity of …\", \"Remove …\". */\n  itemName?: string;\n  label?: string;\n  failedLabel?: string;\n  soldOutLabel?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Fill the width of the container, for product pages and sheets. */\n  block?: boolean;\n  disabled?: boolean;\n  onError?: (error: unknown) => void;\n};\n\nexport function AddToCart({\n  quantity: quantityProp,\n  defaultQuantity = 0,\n  onQuantityChange,\n  onAdd,\n  max,\n  stockMessage,\n  itemName,\n  label = \"Add to cart\",\n  failedLabel = \"Try again\",\n  soldOutLabel = \"Sold out\",\n  size = \"md\",\n  block = false,\n  disabled = false,\n  onError,\n  className,\n  ...rest\n}: AddToCartProps) {\n  const [quantity, setQuantity] = useControllableState({ value: quantityProp, defaultValue: defaultQuantity, onChange: onQuantityChange });\n  const reduce = useReducedMotion();\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [spinner, setSpinner] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const soldOut = max === 0;\n  const inCart = quantity > 0;\n\n  const addButton = useRef<HTMLButtonElement>(null);\n  const shell = useRef<HTMLDivElement>(null);\n  // Where focus should land after the morph, when the control that had it disappears.\n  const refocus = useRef<\"add\" | \"count\" | null>(null);\n  const timers = useRef<number[]>([]);\n  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);\n  const later = (fn: () => void, ms: number) => timers.current.push(window.setTimeout(fn, ms));\n\n  useEffect(() => {\n    const target = refocus.current;\n    refocus.current = null;\n    if (target === \"add\") addButton.current?.focus();\n    if (target === \"count\") shell.current?.querySelector(\"input\")?.focus();\n  }, [inCart]);\n\n  const add = async (keyboard: boolean) => {\n    if (phase === \"busy\" || disabled || soldOut) return;\n    const done = () => {\n      if (keyboard) refocus.current = \"count\";\n      setPhase(\"idle\");\n      setSpinner(false);\n      setQuantity(1);\n      setAnnounce(itemName ? `Added ${itemName} to your cart` : \"Added to your cart\");\n    };\n    const result = onAdd?.();\n    if (!(result instanceof Promise)) return done();\n\n    setPhase(\"busy\");\n    // No spinner for fast answers; once shown, it stays long enough to read as intentional.\n    let shownAt = 0;\n    const t = window.setTimeout(() => {\n      shownAt = performance.now();\n      setSpinner(true);\n    }, 150);\n    try {\n      await result;\n      window.clearTimeout(t);\n      const wait = shownAt ? Math.max(0, 300 - (performance.now() - shownAt)) : 0;\n      if (wait) later(done, wait);\n      else done();\n    } catch (error) {\n      window.clearTimeout(t);\n      setSpinner(false);\n      setPhase(\"failed\");\n      setAnnounce(itemName ? `Couldn’t add ${itemName} to your cart. Try again.` : \"Couldn’t add to your cart. Try again.\");\n      onError?.(error);\n      later(() => setPhase((p) => (p === \"failed\" ? \"idle\" : p)), 2800);\n    }\n  };\n\n  const text = soldOut ? soldOutLabel : phase === \"failed\" ? failedLabel : label;\n  const h = size === \"sm\" ? \"h-7 text-[12px]\" : size === \"lg\" ? \"h-9 text-[13px]\" : \"h-8 text-[12.5px]\";\n\n  // The shell owns the fill and animates its size; the content inside only crossfades.\n  // The resting pose is the same either way, so server and client render the same styles.\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, filter: \"blur(3px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.94, filter: \"blur(3px)\", transition: { duration: 0.12 } },\n  };\n\n  return (\n    <div\n      data-state={inCart ? \"in-cart\" : soldOut ? \"sold-out\" : phase}\n      data-size={size}\n      className={cn(block ? \"flex w-full\" : \"inline-flex\", className)}\n      {...rest}\n    >\n      <motion.div\n        ref={shell}\n        layout={!reduce && !block}\n        transition={spring.snappy}\n        style={{ borderRadius: size === \"sm\" ? 6 : 8 }}\n        className={cn(\n          \"relative flex items-center transition-[background-color,opacity,scale] duration-200\",\n          // The whole fill presses, not just the label inside it.\n          \"has-[>button:not([aria-busy]):not(:disabled):active]:scale-[0.97] has-[>button:active]:duration-75\",\n          phase === \"failed\" ? \"bg-danger\" : \"bg-fg\",\n          (soldOut || disabled) && \"opacity-50\",\n          block && \"w-full\",\n          h,\n        )}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {inCart ? (\n            <motion.div key=\"stepper\" layout={!reduce && !block} className={cn(\"flex h-full\", block && \"w-full\")} transition={{ layout: spring.snappy, duration: 0.2, ease: ease.out }} {...swap}>\n              <QuantityStepper\n                bare\n                variant=\"primary\"\n                size={size}\n                value={quantity}\n                onValueChange={setQuantity}\n                max={max}\n                stockMessage={stockMessage}\n                disabled={disabled}\n                label={itemName ? `Quantity of ${itemName} in cart` : \"Quantity in cart\"}\n                removeLabel={itemName ? `Remove ${itemName} from cart` : \"Remove from cart\"}\n                onRemove={() => {\n                  if (shell.current?.contains(document.activeElement)) refocus.current = \"add\";\n                  setQuantity(0);\n                  setAnnounce(itemName ? `Removed ${itemName} from your cart` : \"Removed from your cart\");\n                }}\n                className={cn(\"h-full\", block && \"w-full\")}\n              />\n            </motion.div>\n          ) : (\n            <motion.button\n              key=\"add\"\n              ref={addButton}\n              type=\"button\"\n              layout={!reduce && !block}\n              disabled={disabled || soldOut}\n              aria-busy={phase === \"busy\" || undefined}\n              aria-disabled={phase === \"busy\" || undefined}\n              // detail is 0 for Enter and Space, so focus follows keyboard users into the stepper.\n              onClick={(e) => add(e.detail === 0)}\n              transition={{ layout: spring.snappy, duration: 0.2, ease: ease.out }}\n              {...swap}\n              className={cn(\n                \"group/add relative inline-flex h-full select-none items-center justify-center gap-2 whitespace-nowrap font-medium tracking-[-0.005em] text-frame outline-none\",\n                \"rounded-[inherit] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"aria-busy:cursor-progress disabled:cursor-not-allowed\",\n                size === \"sm\" ? \"px-2.5\" : size === \"lg\" ? \"px-4\" : \"px-3\",\n                block && \"w-full\",\n              )}\n            >\n              <span className={cn(\"flex items-center gap-2 transition-opacity duration-150\", spinner && \"opacity-0\")}>\n                <span className=\"relative grid size-4 place-items-center\">\n                  <AnimatePresence initial={false}>\n                    <motion.span\n                      key={phase === \"failed\" ? \"failed\" : \"cart\"}\n                      className=\"absolute inset-0 grid place-items-center\"\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                      animate={{ opacity: 1, scale: 1 }}\n                      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                      transition={reduce ? { duration: 0.12 } : spring.pop}\n                    >\n                      {phase === \"failed\" ? <Alert size={size === \"sm\" ? 14 : 16} /> : <Cart size={size === \"sm\" ? 14 : 16} />}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                {/* The spinner overlays the label, so busy never changes the width; a failure may,\n                    and the shell morphs to it instead of jumping. */}\n                <span>{text}</span>\n              </span>\n              {spinner && (\n                <span className=\"absolute inset-0 grid place-items-center\">\n                  <Loader size={16} className=\"animate-spin motion-reduce:animate-none\" />\n                  <span className=\"sr-only\">Adding</span>\n                </span>\n              )}\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </motion.div>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\nexport type CartButtonProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  /** Items in the cart. The badge rolls to the new number and bumps when it grows. */\n  count: number;\n  label?: string;\n  size?: \"sm\" | \"md\";\n};\n\n/** The cart in the header. Its badge rolls and gives a small bump each time something is added. */\nexport function CartButton({ count, label = \"Cart\", size = \"md\", className, ...rest }: CartButtonProps) {\n  const reduce = useReducedMotion();\n  const [scope, animate] = useAnimate();\n  const prev = useRef(count);\n\n  useEffect(() => {\n    const grew = count > prev.current;\n    prev.current = count;\n    if (!grew || reduce || !scope.current) return;\n    animate(\"[data-bump]\", { scale: [1, 1.22, 1] }, { duration: 0.36, ease: ease.out });\n    animate(\"[data-cart]\", { y: [0, 1.5, 0], rotate: [0, -6, 0] }, { duration: 0.32, ease: ease.out });\n  }, [count, reduce, animate, scope]);\n\n  const items = count === 1 ? \"1 item\" : `${count} items`;\n\n  return (\n    <button\n      ref={scope}\n      type=\"button\"\n      aria-label={count > 0 ? `${label}, ${items}` : label}\n      className={cn(\n        \"relative grid shrink-0 place-items-center rounded-lg text-fg-2 outline-none\",\n        \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.94] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n        size === \"sm\" ? \"size-7\" : \"size-8\",\n        className,\n      )}\n      {...rest}\n    >\n      <span data-cart className=\"grid place-items-center\">\n        <Cart size={size === \"sm\" ? 15 : 16} />\n      </span>\n      <AnimatePresence initial={false}>\n        {count > 0 && (\n          <motion.span\n            key=\"badge\"\n            aria-hidden\n            className=\"absolute -right-1 -top-1 flex\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n          >\n            <span\n              data-bump\n              className=\"tabular flex h-4 min-w-4 items-center justify-center rounded-full bg-fg px-1 text-[10px] font-medium leading-none text-frame ring-2 ring-frame\"\n            >\n              <NumberFlow value={count} animated={!reduce} className=\"leading-none\" />\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/add-to-cart"
}
