{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "add-to-cart-button",
  "type": "registry:ui",
  "title": "Add to Cart Button",
  "description": "Throws the product along a parabola into the cart, bumps it, rolls the badge and confirms with a check.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/add-to-cart-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\ntype AddToCartSize = \"sm\" | \"md\" | \"lg\";\ntype ElementTarget = React.RefObject<HTMLElement | null> | string;\n\nexport interface AddToCartButtonProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"button\">,\n    \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  /** The cart element the item flies into (ref or CSS selector). Measured at click time. Optional. */\n  target?: ElementTarget;\n  /** Element cloned as the flying thumbnail (ref or selector), e.g. the product image. Default: an accent dot. */\n  source?: ElementTarget;\n  /** Fires immediately on click — put your cart / API call here. */\n  onAdd?: () => void;\n  /** Fires when the item lands in the cart (immediately without a target or with reduced motion). */\n  onArrive?: () => void;\n  /** Current number of items, used for the live announcement \"Added to cart, 3 items\". */\n  cartCount?: number;\n  label?: string;\n  /** Text of the brief confirmation state. */\n  addedLabel?: string;\n  /** Flight time in ms. */\n  duration?: number;\n  /** Extra height of the parabola above the straight line between the two points, in px. */\n  arc?: number;\n  /** How long the \"Added\" state lasts, in ms. */\n  resetAfter?: number;\n  /** Dot colour, check badge and focus ring. */\n  accent?: string;\n  size?: AddToCartSize;\n}\n\nconst SIZES: Record<AddToCartSize, { h: string; px: string; text: string; icon: string }> = {\n  sm: { h: \"h-9\", px: \"px-3.5\", text: \"text-[13px]\", icon: \"size-4\" },\n  md: { h: \"h-11\", px: \"px-5\", text: \"text-sm\", icon: \"size-[18px]\" },\n  lg: { h: \"h-13\", px: \"px-6\", text: \"text-[15px]\", icon: \"size-5\" },\n};\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nfunction resolve(target: ElementTarget | undefined): HTMLElement | null {\n  if (!target) return null;\n  if (typeof target === \"string\") return document.querySelector<HTMLElement>(target);\n  return target.current;\n}\n\nfunction visibleRect(el: HTMLElement | null) {\n  if (!el) return null;\n  const rect = el.getBoundingClientRect();\n  return rect.width > 0 && rect.height > 0 ? rect : null;\n}\n\n/** Builds the flying element: a clone of `source`, or a small accent dot centred on `from`. */\nfunction createFlyer(source: HTMLElement | null, from: DOMRect, accent: string) {\n  let el: HTMLElement;\n  if (source) {\n    el = source.cloneNode(true) as HTMLElement;\n    el.removeAttribute(\"id\");\n    el.querySelectorAll(\"[id]\").forEach((node) => node.removeAttribute(\"id\"));\n    el.style.borderRadius = getComputedStyle(source).borderRadius;\n    el.style.boxShadow = \"0 14px 30px -12px rgb(0 0 0 / 0.5)\";\n  } else {\n    el = document.createElement(\"div\");\n    el.style.background = accent;\n    el.style.borderRadius = \"9999px\";\n    el.style.boxShadow = \"0 4px 12px -2px rgb(0 0 0 / 0.35)\";\n  }\n  el.setAttribute(\"aria-hidden\", \"true\");\n  el.inert = true;\n  Object.assign(el.style, {\n    position: \"fixed\",\n    left: `${from.left}px`,\n    top: `${from.top}px`,\n    width: `${from.width}px`,\n    height: `${from.height}px`,\n    margin: \"0\",\n    boxSizing: \"border-box\",\n    overflow: \"hidden\",\n    pointerEvents: \"none\",\n    zIndex: \"2147483000\",\n    transformOrigin: \"50% 50%\",\n    willChange: \"transform, opacity\",\n  });\n  return el;\n}\n\n/**\n * An add-to-cart button whose click throws the product (a clone of `source`, or a dot) along a\n * parabola into the cart element, bumps it, and briefly confirms with a check. The flight is a\n * Web Animations keyframe path on a detached element — no React renders per frame.\n */\nexport const AddToCartButton = React.forwardRef<HTMLButtonElement, AddToCartButtonProps>(function AddToCartButton(\n  {\n    target,\n    source,\n    onAdd,\n    onArrive,\n    cartCount,\n    label = \"Add to cart\",\n    addedLabel = \"Added\",\n    duration = 800,\n    arc = 120,\n    resetAfter = 1600,\n    accent = \"#FF7448\",\n    size = \"md\",\n    className = \"\",\n    style,\n    onClick,\n    disabled,\n    type = \"button\",\n    ...props\n  },\n  forwardedRef,\n) {\n  const reduceMotion = usePrefersReducedMotion();\n  const s = SIZES[size] ?? SIZES.md;\n  const [added, setAdded] = React.useState(false);\n  // Bumped on every landing; the announcement text is derived from it and the latest cartCount.\n  const [arrivals, setArrivals] = React.useState(0);\n  const resetTimer = React.useRef(0);\n  const flights = React.useRef(new Set<Animation>());\n  const mounted = React.useRef(true);\n  const latest = React.useRef({ onArrive });\n  React.useEffect(() => {\n    latest.current = { onArrive };\n  });\n\n  React.useEffect(() => {\n    mounted.current = true;\n    const active = flights.current;\n    return () => {\n      mounted.current = false;\n      window.clearTimeout(resetTimer.current);\n      active.forEach((animation) => {\n        animation.cancel();\n        (animation.effect as KeyframeEffect | null)?.target?.remove();\n      });\n      active.clear();\n    };\n  }, []);\n\n  const confirm = () => {\n    setAdded(true);\n    window.clearTimeout(resetTimer.current);\n    resetTimer.current = window.setTimeout(() => setAdded(false), Math.max(400, resetAfter));\n  };\n\n  const land = () => {\n    if (!mounted.current) return;\n    setArrivals((n) => n + 1);\n    latest.current.onArrive?.();\n  };\n\n  const launch = (button: HTMLButtonElement) => {\n    const cart = resolve(target);\n    const to = visibleRect(cart);\n    if (!cart || !to || reduceMotion || typeof document.body.animate !== \"function\") {\n      land();\n      return;\n    }\n    const sourceEl = resolve(source);\n    const sourceRect = visibleRect(sourceEl);\n    const buttonRect = button.getBoundingClientRect();\n    const dot = Math.round(Math.min(18, buttonRect.height * 0.42));\n    const from =\n      sourceEl && sourceRect\n        ? sourceRect\n        : new DOMRect(buttonRect.left + buttonRect.width / 2 - dot / 2, buttonRect.top + buttonRect.height / 2 - dot / 2, dot, dot);\n    const flyer = createFlyer(sourceEl && sourceRect ? sourceEl : null, from, accent);\n    // Inside a modal <dialog> the top layer would cover a body-level flyer.\n    (button.closest(\"dialog[open]\") ?? document.body).appendChild(flyer);\n\n    // Centre-to-centre parabola, sampled into keyframes; easing on the whole effect keeps the path.\n    const dx = to.left + to.width / 2 - (from.left + from.width / 2);\n    const dy = to.top + to.height / 2 - (from.top + from.height / 2);\n    const lift = Math.max(0, arc);\n    const endScale = Math.min(1, 22 / Math.max(from.width, from.height));\n    const steps = 28;\n    const keyframes: Keyframe[] = [];\n    for (let i = 0; i <= steps; i++) {\n      const t = i / steps;\n      const x = dx * t;\n      const y = dy * t - lift * 4 * t * (1 - t);\n      // Shrink early (ease-out cubic) so a large clone reads as a thumbnail for most of the flight.\n      const scale = 1 + (endScale - 1) * (1 - Math.pow(1 - t, 3));\n      keyframes.push({\n        offset: t,\n        transform: `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${scale.toFixed(3)})`,\n        opacity: t < 0.88 ? 1 : 1 - (t - 0.88) / 0.12,\n      });\n    }\n    const flight = flyer.animate(keyframes, { duration: Math.max(200, duration), easing: \"cubic-bezier(0.45, 0, 0.55, 1)\", fill: \"forwards\" });\n    flights.current.add(flight);\n    flight.onfinish = () => {\n      flights.current.delete(flight);\n      flyer.remove();\n      if (!mounted.current) return;\n      // Bump the cart: individual `scale` composes with any transform the cart already has.\n      cart.animate([{ scale: \"1\" }, { scale: \"1.2\" }, { scale: \"0.94\" }, { scale: \"1.03\" }, { scale: \"1\" }], {\n        duration: 460,\n        easing: \"ease-out\",\n      });\n      land();\n    };\n  };\n\n  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    onClick?.(event);\n    if (event.defaultPrevented || disabled) return;\n    onAdd?.();\n    confirm();\n    launch(event.currentTarget);\n  };\n\n  const announcement =\n    arrivals === 0\n      ? \"\"\n      : `${cartCount === undefined ? \"Added to cart\" : `Added to cart, ${cartCount} ${cartCount === 1 ? \"item\" : \"items\"}`}${arrivals % 2 ? \"\" : \"\\u00a0\"}`;\n\n  const swap = reduceMotion\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.12 } }\n    : {\n        initial: { opacity: 0, y: 14, filter: \"blur(2px)\" },\n        animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n        exit: { opacity: 0, y: -14, filter: \"blur(2px)\" },\n        transition: { type: \"spring\" as const, stiffness: 460, damping: 32 },\n      };\n\n  return (\n    <>\n      <button\n        {...props}\n        ref={forwardedRef}\n        type={type}\n        disabled={disabled}\n        onClick={handleClick}\n        className={`group/atc relative inline-flex ${s.h} ${s.px} ${s.text} touch-manipulation select-none items-center justify-center overflow-hidden rounded-full bg-zinc-900 font-medium tracking-tight text-white shadow-[0_1px_0_rgb(255_255_255/0.08)_inset,0_10px_24px_-14px_rgb(0_0_0/0.6)] outline-hidden transition-[background-color,scale] duration-150 [-webkit-tap-highlight-color:transparent] hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--atc-accent)] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-45 disabled:active:scale-100 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white ${className}`}\n        style={{ [\"--atc-accent\" as string]: accent, ...style }}\n      >\n        {/* Both labels share one grid cell so the width never jumps. */}\n        <span className=\"grid items-center [grid-template-areas:'stack']\">\n          <span aria-hidden=\"true\" className=\"invisible flex items-center gap-2 [grid-area:stack]\">\n            <span className={s.icon} />\n            {label.length >= addedLabel.length ? label : addedLabel}\n          </span>\n          <AnimatePresence initial={false}>\n            {added ? (\n              <motion.span key=\"added\" className=\"flex items-center justify-center gap-2 [grid-area:stack]\" {...swap}>\n                <span className={`grid ${s.icon} place-items-center rounded-full bg-[var(--atc-accent)] text-white`}>\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-[70%]\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                    <motion.path\n                      d=\"m3.5 8.4 2.9 2.8 6.1-6.4\"\n                      initial={{ pathLength: reduceMotion ? 1 : 0 }}\n                      animate={{ pathLength: 1 }}\n                      transition={reduceMotion ? { duration: 0 } : { duration: 0.32, delay: 0.08, ease: \"easeOut\" }}\n                    />\n                  </svg>\n                </span>\n                {addedLabel}\n              </motion.span>\n            ) : (\n              <motion.span key=\"idle\" className=\"flex items-center justify-center gap-2 [grid-area:stack]\" {...swap}>\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\" className={s.icon} stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <path d=\"M3 4h2.2l2.1 10.4a1.6 1.6 0 0 0 1.6 1.3h7.7a1.6 1.6 0 0 0 1.6-1.2L20 8H6.1\" />\n                  <circle cx=\"9.5\" cy=\"19.5\" r=\"1.2\" />\n                  <circle cx=\"16.5\" cy=\"19.5\" r=\"1.2\" />\n                </svg>\n                {label}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n      </button>\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </>\n  );\n});\n\nexport interface CartIndicatorProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"button\">,\n    \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  /** Items in the cart; the badge rolls to each new value. */\n  count: number;\n  /** Accessible name prefix: \"Cart, 3 items\". */\n  label?: string;\n  /** Badge colour. */\n  accent?: string;\n}\n\n/** A cart icon button with a rolling count badge — a ready-made `target` for AddToCartButton. */\nexport const CartIndicator = React.forwardRef<HTMLButtonElement, CartIndicatorProps>(function CartIndicator(\n  { count, label = \"Cart\", accent = \"#FF7448\", className = \"\", style, type = \"button\", ...props },\n  forwardedRef,\n) {\n  const reduceMotion = usePrefersReducedMotion();\n  const roll = reduceMotion\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }\n    : {\n        initial: { y: \"100%\", opacity: 0 },\n        animate: { y: \"0%\", opacity: 1 },\n        exit: { y: \"-100%\", opacity: 0 },\n        transition: { type: \"spring\" as const, stiffness: 520, damping: 34 },\n      };\n\n  return (\n    <button\n      {...props}\n      ref={forwardedRef}\n      type={type}\n      aria-label={`${label}, ${count} ${count === 1 ? \"item\" : \"items\"}`}\n      className={`relative grid size-11 shrink-0 place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-800 outline-hidden transition-colors hover:border-zinc-900/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--atc-accent)] dark:border-white/10 dark:bg-white/[0.06] dark:text-zinc-100 dark:hover:border-white/20 ${className}`}\n      style={{ [\"--atc-accent\" as string]: accent, ...style }}\n    >\n      <svg viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\" className=\"size-5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <path d=\"M3 4h2.2l2.1 10.4a1.6 1.6 0 0 0 1.6 1.3h7.7a1.6 1.6 0 0 0 1.6-1.2L20 8H6.1\" />\n        <circle cx=\"9.5\" cy=\"19.5\" r=\"1.2\" />\n        <circle cx=\"16.5\" cy=\"19.5\" r=\"1.2\" />\n      </svg>\n      <AnimatePresence initial={false}>\n        {count > 0 ? (\n          <motion.span\n            key=\"badge\"\n            aria-hidden=\"true\"\n            className=\"absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-full bg-[var(--atc-accent)] px-1 font-mono text-[10px] font-medium tabular-nums text-white ring-2 ring-white dark:ring-[#131619]\"\n            initial={reduceMotion ? { opacity: 0 } : { scale: 0 }}\n            animate={reduceMotion ? { opacity: 1 } : { scale: 1 }}\n            exit={reduceMotion ? { opacity: 0 } : { scale: 0 }}\n            transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 26 }}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span key={count} className=\"block\" {...roll}>\n                {count > 99 ? \"99+\" : count}\n              </motion.span>\n            </AnimatePresence>\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n    </button>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/add-to-cart-button"
}
