{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-drawer",
  "type": "registry:ui",
  "title": "Cart Drawer",
  "description": "Slide-over cart whose lines collapse away and whose totals roll as you edit.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/react",
    "@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/quantity-stepper.json"
  ],
  "files": [
    {
      "path": "components/ui/cart-drawer.tsx",
      "content": "\"use client\";\nimport { Drawer } from \"@base-ui/react/drawer\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport { QuantityStepper } from \"@/components/ui/quantity-stepper\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowRight, Cart, Check, Image as ImageIcon, Loader, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\ntype Money = { currency: string; locale: string };\nconst MoneyContext = createContext<Money>({ currency: \"USD\", locale: \"en-US\" });\n\n/** Formats an amount in the drawer's currency. Whole amounts drop the \".00\". */\nexport function useMoney() {\n  const { currency, locale } = useContext(MoneyContext);\n  return (amount: number) =>\n    new Intl.NumberFormat(locale, { style: \"currency\", currency, minimumFractionDigits: Number.isInteger(amount) ? 0 : 2 }).format(amount);\n}\n\nfunction moneyFormat(amount: number, currency: string) {\n  return { style: \"currency\", currency, minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, maximumFractionDigits: 2 } as const;\n}\n\nexport type CartDrawerProps = Omit<Drawer.Root.Props, \"swipeDirection\" | \"snapPoints\" | \"snapPoint\" | \"defaultSnapPoint\" | \"onSnapPointChange\"> & {\n  /** ISO currency code for every amount inside. */\n  currency?: string;\n  /** Locale for number formatting. Set it explicitly so server and client agree. */\n  locale?: string;\n};\n\n/** The cart as a slide-over from the right edge. Swipe right to dismiss on touch. */\nexport function CartDrawer({ currency = \"USD\", locale = \"en-US\", ...rest }: CartDrawerProps) {\n  return (\n    <MoneyContext.Provider value={{ currency, locale }}>\n      <Drawer.Root swipeDirection=\"right\" {...rest} />\n    </MoneyContext.Provider>\n  );\n}\n\nexport type CartDrawerTriggerProps = Drawer.Trigger.Props;\nexport function CartDrawerTrigger(props: CartDrawerTriggerProps) {\n  return <Drawer.Trigger {...props} />;\n}\n\nexport type CartDrawerCloseProps = Omit<Drawer.Close.Props, \"className\"> & { className?: string };\n/** A secondary button that closes the drawer, e.g. \"Continue shopping\". */\nexport function CartDrawerClose({ className, ...rest }: CartDrawerCloseProps) {\n  return (\n    <Drawer.Close\n      className={cn(\n        \"inline-flex h-8 select-none items-center justify-center gap-2 rounded-lg border border-line-2 bg-raised px-3 text-[12.5px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n        \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nexport type CartDrawerContentProps = Omit<Drawer.Popup.Props, \"className\" | \"style\" | \"title\"> & {\n  className?: string;\n  style?: React.CSSProperties;\n  /** Render into this element instead of document.body. Backdrop and panel become absolute to it. */\n  container?: Drawer.Portal.Props[\"container\"];\n  title?: React.ReactNode;\n  /** Units in the cart, shown and rolled beside the title. */\n  count?: number;\n};\n\nexport function CartDrawerContent({ container, title = \"Cart\", count, className, style, children, ...rest }: CartDrawerContentProps) {\n  const contained = container != null;\n  return (\n    <Drawer.Portal container={container}>\n      <Drawer.Backdrop\n        className={cn(\n          contained ? \"absolute\" : \"fixed\",\n          \"inset-0 z-(--z-overlay) bg-overlay\",\n          \"opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-drawer\",\n          \"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-200 data-swiping:duration-0\",\n        )}\n      />\n      <Drawer.Viewport className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-dialog) flex justify-end p-2\")}>\n        <Drawer.Popup\n          style={style}\n          className={cn(\n            \"relative flex h-full w-[400px] max-w-full flex-col overflow-hidden rounded-2xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n            // Slides in on the drawer curve, follows the finger 1:1, and a flick shortens the exit.\n            \"[transform:translateX(var(--drawer-swipe-movement-x,0px))] transition-transform duration-[340ms] ease-drawer data-swiping:duration-0 data-swiping:select-none\",\n            \"data-starting-style:[transform:translateX(calc(100%+8px))] data-ending-style:[transform:translateX(calc(100%+8px))]\",\n            \"data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*240ms)]\",\n            className,\n          )}\n          {...rest}\n        >\n          <div className=\"flex h-13 shrink-0 items-center gap-2 border-b border-line pl-4 pr-2.5\">\n            <Drawer.Title className=\"text-[14px] font-medium tracking-[-0.015em] text-fg\">{title}</Drawer.Title>\n            {count != null && count > 0 && (\n              <span className=\"tabular grid h-5 min-w-5 place-items-center rounded-full bg-hover px-1.5 text-[11px] font-medium text-fg-2\">\n                <NumberFlow value={count} className=\"leading-none\" />\n                <span className=\"sr-only\">{count === 1 ? \" item\" : \" items\"}</span>\n              </span>\n            )}\n            <Drawer.Close\n              aria-label=\"Close cart\"\n              className={cn(\n                \"relative ml-auto grid size-8 place-items-center rounded-lg text-fg-3 outline-none\",\n                \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] 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              )}\n            >\n              <X />\n            </Drawer.Close>\n          </div>\n          <Drawer.Content className=\"flex min-h-0 flex-1 flex-col\">{children}</Drawer.Content>\n        </Drawer.Popup>\n      </Drawer.Viewport>\n    </Drawer.Portal>\n  );\n}\n\nexport type CartLinesProps = React.ComponentProps<\"ul\">;\n\n/** The scrolling list of lines. Hairlines appear at the edges only when there is more to scroll. */\nexport function CartLines({ className, children, ...rest }: CartLinesProps) {\n  const ref = useRef<HTMLUListElement>(null);\n  const [edges, setEdges] = useState({ top: false, bottom: false });\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => {\n      const top = el.scrollTop > 1;\n      const bottom = el.scrollTop + el.clientHeight < el.scrollHeight - 1;\n      setEdges((e) => (e.top === top && e.bottom === bottom ? e : { top, bottom }));\n    };\n    // Lines grow and collapse without scrolling, so watch their sizes too.\n    const ro = new ResizeObserver(read);\n    const watch = () => {\n      ro.disconnect();\n      ro.observe(el);\n      for (const c of el.children) ro.observe(c);\n      read();\n    };\n    const mo = new MutationObserver(watch);\n    mo.observe(el, { childList: true });\n    watch();\n    el.addEventListener(\"scroll\", read, { passive: true });\n    return () => {\n      el.removeEventListener(\"scroll\", read);\n      mo.disconnect();\n      ro.disconnect();\n    };\n  }, []);\n\n  return (\n    <div className=\"relative flex min-h-0 flex-1 flex-col\">\n      <span aria-hidden className={cn(\"pointer-events-none absolute inset-x-0 top-0 z-1 h-px bg-line transition-opacity duration-150\", edges.top ? \"opacity-100\" : \"opacity-0\")} />\n      <ul\n        ref={ref}\n        aria-label=\"Items in your cart\"\n        className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-8 pt-1.5\", className)}\n        {...rest}\n      >\n        <AnimatePresence initial={false}>{children}</AnimatePresence>\n      </ul>\n      <span aria-hidden className={cn(\"pointer-events-none absolute inset-x-0 bottom-0 z-1 h-px bg-line transition-opacity duration-150\", edges.bottom ? \"opacity-100\" : \"opacity-0\")} />\n    </div>\n  );\n}\n\nexport type CartLineProps = Omit<React.ComponentProps<\"li\">, \"onChange\"> & {\n  name: string;\n  /** Size, colour and other options, as one short line. */\n  variant?: string;\n  /** Unit price. The line shows the total and, above 1, the unit price. */\n  price: number;\n  quantity: number;\n  onQuantityChange: (quantity: number) => void;\n  onRemove: () => void;\n  /** An image URL, or any node (a thumbnail component). */\n  image?: string | React.ReactNode;\n  /** Units available. */\n  max?: number;\n  /** Price before a sale, struck through beside the total. */\n  compareAt?: number;\n};\n\nexport function CartLine({ name, variant, price, quantity, onQuantityChange, onRemove, image, max, compareAt, className, ...rest }: CartLineProps) {\n  const { currency, locale } = useContext(MoneyContext);\n  const money = useMoney();\n  const reduce = useReducedMotion();\n  const [moving, setMoving] = useState(false);\n  const total = price * quantity;\n\n  return (\n    <motion.li\n      // Collapse on remove: the rows below close the gap instead of jumping.\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, filter: \"blur(2px)\" }}\n      transition={{ duration: 0.26, ease: ease.inOut }}\n      // Clipped only while the height moves, so the stock hint can overhang the row at rest.\n      onAnimationStart={() => setMoving(true)}\n      onAnimationComplete={() => setMoving(false)}\n      className={cn(\"relative\", moving && \"overflow-hidden\", className)}\n      {...(rest as React.ComponentProps<typeof motion.li>)}\n    >\n      <div className=\"flex gap-3 rounded-xl px-2 py-2.5\">\n        <Thumb image={image} name={name} />\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n          <div className=\"flex items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              <p className=\"text-[13px] font-medium leading-[1.35] text-fg\">{name}</p>\n              {variant && <p className=\"truncate text-[12px] text-fg-3\">{variant}</p>}\n            </div>\n            <div className=\"flex shrink-0 flex-col items-end\">\n              {/* Fixed height: the rolling digits carry their own mask padding, which must not move the row. */}\n              <span className=\"flex h-[18px] items-center\">\n                <NumberFlow value={total} locales={locale} format={moneyFormat(total, currency)} className=\"tabular text-[13px] font-medium leading-none text-fg\" />\n              </span>\n              {/* Always one line tall, so the stepper row never moves when \"each\" appears. */}\n              <span className=\"tabular h-4 text-[11.5px] leading-4 text-fg-4\">\n                {compareAt != null && compareAt > price ? (\n                  <s className=\"decoration-fg-4\">{money(compareAt * quantity)}</s>\n                ) : quantity > 1 ? (\n                  `${money(price)} each`\n                ) : null}\n              </span>\n            </div>\n          </div>\n          <div className=\"flex items-center justify-between gap-3\">\n            <QuantityStepper\n              size=\"sm\"\n              value={quantity}\n              onValueChange={onQuantityChange}\n              max={max}\n              label={`Quantity of ${name}`}\n              onRemove={onRemove}\n              removeLabel={`Remove ${name}`}\n              hintSide=\"bottom\"\n            />\n            <button\n              type=\"button\"\n              onClick={onRemove}\n              aria-label={`Remove ${name}`}\n              className={cn(\n                \"relative rounded-sm text-[12px] text-fg-3 outline-none transition-colors duration-150 hover:text-fg\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"before:absolute before:-inset-x-2 before:-inset-y-3 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            >\n              Remove\n            </button>\n          </div>\n        </div>\n      </div>\n    </motion.li>\n  );\n}\n\nfunction Thumb({ image, name }: { image: CartLineProps[\"image\"]; name: string }) {\n  const [failed, setFailed] = useState(false);\n  const frame = \"relative grid size-16 shrink-0 place-items-center overflow-hidden rounded-lg border border-line bg-hover text-fg-4\";\n  if (typeof image === \"string\" && !failed)\n    return (\n      <span className={frame}>\n        {/* eslint-disable-next-line @next/next/no-img-element -- a copied component can't assume next/image */}\n        <img src={image} alt={name} width={64} height={64} loading=\"lazy\" onError={() => setFailed(true)} className=\"size-full object-cover\" />\n      </span>\n    );\n  return (\n    <span aria-hidden className={frame}>\n      {typeof image === \"string\" || image == null ? <ImageIcon size={18} /> : image}\n    </span>\n  );\n}\n\nexport type CartShippingProgressProps = React.ComponentProps<\"div\"> & {\n  subtotal: number;\n  /** Subtotal at which shipping becomes free. */\n  threshold: number;\n};\n\n/** \"€23 away from free shipping\", with a bar that fills as the cart grows. */\nexport function CartShippingProgress({ subtotal, threshold, className, ...rest }: CartShippingProgressProps) {\n  const { currency, locale } = useContext(MoneyContext);\n  const reduce = useReducedMotion();\n  const left = Math.max(0, threshold - subtotal);\n  const done = left === 0;\n  const progress = Math.min(1, subtotal / threshold);\n\n  return (\n    <div className={cn(\"flex flex-col gap-2\", className)} {...rest}>\n      <p className=\"flex h-4 items-center gap-1.5 text-[12px] text-fg-2\" aria-live=\"polite\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {done ? (\n            <motion.span\n              key=\"done\"\n              className=\"flex items-center gap-1.5 text-fg\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.24, ease: ease.out }}\n            >\n              <Check size={14} className=\"text-success\" />\n              Free shipping unlocked\n            </motion.span>\n          ) : (\n            <motion.span\n              key=\"left\"\n              className=\"flex items-center\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.24, ease: ease.out }}\n            >\n              <NumberFlow value={left} locales={locale} format={moneyFormat(left, currency)} className=\"tabular font-medium text-fg\" />\n              <span>&nbsp;away from free shipping</span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </p>\n      <div\n        role=\"progressbar\"\n        aria-label=\"Progress to free shipping\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(progress * 100)}\n        className=\"h-1 overflow-hidden rounded-full bg-hover\"\n      >\n        <div\n          className={cn(\n            \"h-full origin-left rounded-full transition-[transform,background-color] duration-500 ease-out-expo\",\n            done ? \"bg-success\" : \"bg-fg\",\n          )}\n          style={{ transform: `scaleX(${progress})` }}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport type CartSummaryProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  subtotal: number;\n  /** One quiet line under the subtotal. */\n  note?: React.ReactNode;\n  /** Runs on Check out. Return a promise to show the busy state on the button. */\n  onCheckout?: () => void | Promise<unknown>;\n  checkoutLabel?: string;\n  /** Anything above the totals, like CartShippingProgress. */\n  children?: React.ReactNode;\n};\n\nexport function CartSummary({ subtotal, note = \"Shipping and taxes are calculated at checkout.\", onCheckout, checkoutLabel = \"Check out\", className, children, ...rest }: CartSummaryProps) {\n  const { currency, locale } = useContext(MoneyContext);\n  const [busy, setBusy] = useState(false);\n  const pending = useRef(false);\n  const timer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  return (\n    <div className={cn(\"flex shrink-0 flex-col gap-3 border-t border-line px-4 pb-4 pt-3.5\", className)} {...rest}>\n      {children}\n      <div className=\"flex h-5 items-center justify-between\">\n        <span className=\"text-[13px] text-fg-2\">Subtotal</span>\n        <NumberFlow value={subtotal} locales={locale} format={moneyFormat(subtotal, currency)} className=\"tabular text-[15px] font-medium leading-none tracking-[-0.01em] text-fg\" />\n      </div>\n      {note && <p className=\"-mt-2 text-[12px] text-fg-3\">{note}</p>}\n      <button\n        type=\"button\"\n        aria-busy={busy || undefined}\n        onClick={async () => {\n          // One checkout at a time, even before the spinner shows.\n          if (pending.current) return;\n          const result = onCheckout?.();\n          if (!(result instanceof Promise)) return;\n          pending.current = true;\n          const shownAt = { t: 0 };\n          timer.current = window.setTimeout(() => {\n            shownAt.t = performance.now();\n            setBusy(true);\n          }, 150);\n          try {\n            await result;\n          } finally {\n            window.clearTimeout(timer.current);\n            const hold = shownAt.t ? Math.max(0, 300 - (performance.now() - shownAt.t)) : 0;\n            timer.current = window.setTimeout(() => {\n              pending.current = false;\n              setBusy(false);\n            }, hold);\n          }\n        }}\n        className={cn(\n          \"group/checkout relative inline-flex h-9 w-full select-none items-center justify-center rounded-lg bg-fg text-[13px] font-medium text-frame outline-none\",\n          \"transition-[background-color,scale] duration-150 hover:bg-fg/90 active:scale-[0.98] active:duration-75 aria-busy:cursor-progress aria-busy:active:scale-100\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        )}\n      >\n        <span className={cn(\"flex items-center gap-2 transition-opacity duration-150\", busy && \"opacity-0\")}>\n          {checkoutLabel}\n          <ArrowRight size={15} className=\"transition-transform duration-200 ease-out-expo group-hover/checkout:translate-x-0.5\" />\n        </span>\n        {busy && (\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\">Starting checkout</span>\n          </span>\n        )}\n      </button>\n    </div>\n  );\n}\n\nexport type CartEmptyProps = React.ComponentProps<\"div\"> & {\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  /** One action, usually a CartDrawerClose that says \"Continue shopping\". */\n  action?: React.ReactNode;\n};\n\nexport function CartEmpty({ title = \"Your cart is empty\", description, action, className, ...rest }: CartEmptyProps) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.div\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.28, ease: ease.out, delay: 0.12 }}\n      className={cn(\"flex flex-1 flex-col items-center justify-center gap-3 px-6 pb-10 text-center\", className)}\n      {...(rest as React.ComponentProps<typeof motion.div>)}\n    >\n      <span aria-hidden className=\"grid size-10 place-items-center rounded-full border border-line bg-hover text-fg-3\">\n        <Cart />\n      </span>\n      <div className=\"flex flex-col gap-1\">\n        <p className=\"text-[13px] font-medium text-fg\">{title}</p>\n        {description && <p className=\"max-w-[28ch] text-balance text-[12.5px] text-fg-3\">{description}</p>}\n      </div>\n      {action && <div className=\"pt-1\">{action}</div>}\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/cart-drawer"
}
