{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-drawer",
  "type": "registry:block",
  "title": "Cart drawer",
  "description": "Right-side cart with a free-shipping meter, animated line items, a two-item upsell, a collapsible discount code and totals. Focus trap, Escape, scroll lock.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "cart",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/button.json",
    "https://storekit-eight.vercel.app/r/input.json",
    "https://storekit-eight.vercel.app/r/price.json",
    "https://storekit-eight.vercel.app/r/product-image.json",
    "https://storekit-eight.vercel.app/r/stepper.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "cart/cart-drawer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, ChevronDown, Lock, Plus, Trash2, Truck, X } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { Input } from \"@/components/storekit/ui/input\"\nimport { Price } from \"@/components/storekit/ui/price\"\nimport { ProductImage } from \"@/components/storekit/ui/product-image\"\nimport { Stepper } from \"@/components/storekit/ui/stepper\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* The flagship cart. A right-side drawer with a free-shipping meter, animated\n   line items, a two-up upsell row, a collapsible discount code and totals.\n   Pass `contained` to pin it inside a relative parent instead of the viewport\n   (no body scroll lock, no focus grab on first mount). */\n\nexport type CartDrawerLine = {\n  id: string\n  title: string\n  variant?: string\n  price: number\n  compareAt?: number\n  image: string\n  quantity: number\n}\n\nexport type CartDrawerUpsell = { id: string; title: string; variant?: string; price: number; image: string }\n\nconst SAMPLE_LINES: CartDrawerLine[] = [\n  { id: \"daily-greens\", title: \"Daily Greens\", variant: \"30 servings\", price: 4900, image: \"/brands/morrow/greens.webp\", quantity: 1 },\n  { id: \"electrolytes\", title: \"Electrolytes\", variant: \"30 sticks\", price: 3200, image: \"/brands/morrow/electrolytes.webp\", quantity: 1 },\n  { id: \"magnesium-night\", title: \"Magnesium Night\", variant: \"60 gummies\", price: 2900, image: \"/brands/morrow/magnesium.webp\", quantity: 1 },\n]\n\nconst SAMPLE_UPSELLS: CartDrawerUpsell[] = [\n  { id: \"bac-water\", title: \"Electrolytes Travel Pack\", variant: \"10 sticks\", price: 1200, image: \"/brands/morrow/electrolytes.webp\" },\n  { id: \"protein\", title: \"Protein\", variant: \"30 servings\", price: 5400, image: \"/brands/morrow/protein.webp\" },\n]\n\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])'\n\nexport function CartDrawer({\n  open,\n  onOpenChange,\n  items = SAMPLE_LINES,\n  upsells = SAMPLE_UPSELLS,\n  freeShippingThreshold = 15000,\n  discountCodes = { WELCOME10: 10 },\n  contained = false,\n  onCheckout,\n  onLinesChange,\n  className,\n}: {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  /** Initial lines. The drawer owns quantity and removal state after mount. */\n  items?: CartDrawerLine[]\n  upsells?: CartDrawerUpsell[]\n  /** In cents. */\n  freeShippingThreshold?: number\n  /** Code to percent off. */\n  discountCodes?: Record<string, number>\n  contained?: boolean\n  onCheckout?: (lines: CartDrawerLine[]) => void\n  /** Fires after every quantity change, removal or upsell add, e.g. to keep a header count in sync. */\n  onLinesChange?: (lines: CartDrawerLine[]) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"cart\", \"cart-drawer\")\n  const [lines, setLines] = React.useState(items)\n  const [codeOpen, setCodeOpen] = React.useState(false)\n  const [code, setCode] = React.useState(\"\")\n  const [codeError, setCodeError] = React.useState<string>()\n  const [applied, setApplied] = React.useState<{ code: string; pct: number } | null>(null)\n  const [checkingOut, setCheckingOut] = React.useState(false)\n  const panelRef = React.useRef<HTMLDivElement>(null)\n  const closeRef = React.useRef<HTMLButtonElement>(null)\n  const firstMount = React.useRef(true)\n  const titleId = React.useId()\n  const codeRegion = React.useId()\n\n  const count = lines.reduce((n, l) => n + l.quantity, 0)\n  const subtotal = lines.reduce((n, l) => n + l.price * l.quantity, 0)\n  const discount = applied ? Math.round((subtotal * applied.pct) / 100) : 0\n  const afterDiscount = subtotal - discount\n  const remaining = Math.max(0, freeShippingThreshold - afterDiscount)\n  const unlocked = remaining === 0 && lines.length > 0\n  const pct = Math.min(100, (afterDiscount / freeShippingThreshold) * 100)\n\n  // Scroll lock while open (viewport mode only).\n  React.useEffect(() => {\n    if (!open || contained) return\n    const prev = document.body.style.overflow\n    document.body.style.overflow = \"hidden\"\n    return () => {\n      document.body.style.overflow = prev\n    }\n  }, [open, contained])\n\n  // Report line changes (not the initial lines) to the parent.\n  const linesChange = React.useRef(onLinesChange)\n  linesChange.current = onLinesChange\n  const initialLines = React.useRef(lines)\n  React.useEffect(() => {\n    if (lines !== initialLines.current) linesChange.current?.(lines)\n  }, [lines])\n\n  // Move focus in on open and hand it back to the opener on close. Skip the\n  // very first mount in contained mode so a gallery card that renders open\n  // does not steal focus or scroll the page.\n  React.useEffect(() => {\n    const skip = firstMount.current && contained\n    firstMount.current = false\n    if (!open || skip) return\n    const opener = document.activeElement as HTMLElement | null\n    const t = window.setTimeout(() => closeRef.current?.focus({ preventScroll: true }), 30)\n    return () => {\n      window.clearTimeout(t)\n      if (opener && opener !== document.body && opener.isConnected) opener.focus({ preventScroll: true })\n    }\n  }, [open, contained])\n\n  // Escape closes.\n  React.useEffect(() => {\n    if (!open) return\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") onOpenChange(false)\n    }\n    document.addEventListener(\"keydown\", onKey)\n    return () => document.removeEventListener(\"keydown\", onKey)\n  }, [open, onOpenChange])\n\n  // Focus trap: keep Tab inside the panel.\n  const onPanelKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key !== \"Tab\" || !panelRef.current) return\n    const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))\n    if (!nodes.length) return\n    const first = nodes[0]\n    const last = nodes[nodes.length - 1]\n    if (e.shiftKey && document.activeElement === first) {\n      e.preventDefault()\n      last.focus()\n    } else if (!e.shiftKey && document.activeElement === last) {\n      e.preventDefault()\n      first.focus()\n    }\n  }\n\n  const setQty = (id: string, quantity: number) => setLines((ls) => ls.map((l) => (l.id === id ? { ...l, quantity } : l)))\n  const remove = (id: string) => setLines((ls) => ls.filter((l) => l.id !== id))\n  const addUpsell = (u: CartDrawerUpsell) => {\n    sk.emit(\"interact\", \"upsell_add\", u.price)\n    setLines((ls) => (ls.some((l) => l.id === u.id) ? ls.map((l) => (l.id === u.id ? { ...l, quantity: l.quantity + 1 } : l)) : [...ls, { ...u, quantity: 1 }]))\n  }\n\n  const applyCode = () => {\n    const key = code.trim().toUpperCase()\n    if (!key) return setCodeError(\"Enter a code.\")\n    const off = discountCodes[key]\n    if (!off) return setCodeError(\"That code is not valid.\")\n    setApplied({ code: key, pct: off })\n    setCodeError(undefined)\n    setCode(\"\")\n  }\n\n  const checkout = () => {\n    sk.emit(\"convert\", \"checkout\", afterDiscount)\n    setCheckingOut(true)\n    window.setTimeout(() => {\n      setCheckingOut(false)\n      onCheckout?.(lines)\n    }, 900)\n  }\n\n  const d = reduce ? 0 : 1\n  const position = contained ? \"absolute\" : \"fixed\"\n\n  return (\n    <AnimatePresence>\n      {open ? (\n        <div key=\"sk-cart-drawer\" ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn(position, \"inset-0 z-50\", className)}>\n          <motion.div\n            aria-hidden\n            className=\"absolute inset-0 bg-sk-ink/40\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.2 * d }}\n            onClick={() => onOpenChange(false)}\n          />\n          <motion.div\n            ref={panelRef}\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-labelledby={titleId}\n            onKeyDown={onPanelKeyDown}\n            className=\"absolute inset-y-0 right-0 flex w-full max-w-[420px] flex-col border-l border-sk-line bg-sk-paper shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)]\"\n            initial={{ x: \"100%\" }}\n            animate={{ x: 0 }}\n            exit={{ x: \"100%\" }}\n            transition={{ duration: 0.34 * d, ease: [0.32, 0.72, 0, 1] }}\n          >\n            {/* Header */}\n            <div className=\"flex items-center justify-between border-b border-sk-line px-5 py-3\">\n              <h2 id={titleId} className=\"font-sk-display text-lg font-semibold tracking-[-0.02em]\">\n                Your cart <span className=\"tabular-nums text-sk-muted\">({count})</span>\n              </h2>\n              <button\n                ref={closeRef}\n                type=\"button\"\n                aria-label=\"Close cart\"\n                onClick={() => onOpenChange(false)}\n                className=\"-mr-2 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-ink transition-colors duration-200 hover:bg-sk-ink/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]\"\n              >\n                <X size={18} />\n              </button>\n            </div>\n\n            {/* Free shipping meter */}\n            <div className=\"border-b border-sk-line px-5 py-4\">\n              <p className=\"flex items-center gap-2 text-[13px] text-sk-ink\" aria-live=\"polite\">\n                {unlocked ? <Check size={14} className=\"text-sk-savings\" /> : <Truck size={14} className=\"text-sk-muted\" />}\n                {unlocked ? (\n                  <span className=\"font-medium text-sk-savings\">Free shipping unlocked</span>\n                ) : (\n                  <span>\n                    You are <span className=\"font-semibold tabular-nums\">{money(remaining)}</span> away from free shipping\n                  </span>\n                )}\n              </p>\n              <div className=\"mt-2.5 h-1.5 overflow-hidden rounded-full bg-sk-ink/[0.08]\">\n                <motion.div\n                  className={cn(\"h-full w-full origin-left rounded-full transition-colors duration-300\", unlocked ? \"bg-sk-savings\" : \"bg-sk-accent\")}\n                  initial={false}\n                  animate={{ scaleX: pct / 100 }}\n                  transition={{ duration: 0.5 * d, ease: [0.16, 1, 0.3, 1] }}\n                />\n              </div>\n            </div>\n\n            {/* Body */}\n            <div className=\"flex-1 overflow-y-auto overscroll-contain\">\n              {lines.length === 0 ? (\n                <div className=\"grid place-items-center gap-3 px-5 py-16 text-center\">\n                  <p className=\"font-sk-display text-lg font-semibold tracking-[-0.02em]\">Your cart is empty.</p>\n                  <p className=\"text-sm text-sk-muted\">The best sellers are a good place to start.</p>\n                </div>\n              ) : (\n                <ul className=\"px-5\">\n                  <AnimatePresence initial={false}>\n                    {lines.map((l) => (\n                      <motion.li\n                        key={l.id}\n                        layout={!reduce}\n                        initial={{ opacity: 0, height: 0 }}\n                        animate={{ opacity: 1, height: \"auto\" }}\n                        exit={{ opacity: 0, height: 0, x: reduce ? 0 : 24 }}\n                        transition={{ duration: 0.32 * d, ease: [0.16, 1, 0.3, 1] }}\n                        className=\"overflow-hidden border-b border-sk-line last:border-b-0\"\n                      >\n                        <div className=\"flex gap-3 py-4\">\n                          <ProductImage src={l.image} alt={l.title} className=\"h-20 w-20 shrink-0 rounded-sk-card border border-sk-line\" fit=\"cover\" zoomOnHover={false} />\n                          <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n                            <div className=\"flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2\">\n                              <div className=\"min-w-0\">\n                                <p className=\"line-clamp-2 text-sm font-medium text-sk-ink\">{l.title}</p>\n                                {l.variant ? <p className=\"text-[13px] text-sk-muted\">{l.variant}</p> : null}\n                              </div>\n                              <Price amount={l.price * l.quantity} compareAt={l.compareAt ? l.compareAt * l.quantity : undefined} size=\"sm\" className=\"shrink-0 sm:justify-end\" />\n                            </div>\n                            <div className=\"flex items-center justify-between\">\n                              <Stepper label={`Quantity, ${l.title}`} value={l.quantity} onChange={(q) => setQty(l.id, q)} size=\"sm\" />\n                              <button\n                                type=\"button\"\n                                aria-label={`Remove ${l.title}`}\n                                onClick={() => remove(l.id)}\n                                className=\"-mr-2 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-colors duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]\"\n                              >\n                                <Trash2 size={16} />\n                              </button>\n                            </div>\n                          </div>\n                        </div>\n                      </motion.li>\n                    ))}\n                  </AnimatePresence>\n                </ul>\n              )}\n\n              {/* Upsell row */}\n              {upsells.length ? (\n                <div className=\"border-t border-sk-line bg-sk-surface/60 px-5 py-4\">\n                  <p className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">Pairs well with</p>\n                  <div className=\"mt-3 grid gap-2\">\n                    {upsells.slice(0, 2).map((u) => {\n                      const inCart = lines.some((l) => l.id === u.id)\n                      return (\n                        <div key={u.id} className=\"flex items-center gap-2.5 rounded-sk-card border border-sk-line bg-sk-card p-2\">\n                          <ProductImage src={u.image} alt={u.title} className=\"h-11 w-11 shrink-0 rounded-sk-chip\" fit=\"cover\" zoomOnHover={false} />\n                          <div className=\"min-w-0 flex-1\">\n                            <p className=\"truncate text-[13px] font-medium leading-tight\">{u.title}</p>\n                            <p className=\"text-[13px] tabular-nums text-sk-muted\">\n                              {u.variant ? `${u.variant} / ` : null}\n                              {money(u.price)}\n                            </p>\n                          </div>\n                          <button\n                            type=\"button\"\n                            aria-label={inCart ? `Add another ${u.title}` : `Add ${u.title}`}\n                            onClick={() => addUpsell(u)}\n                            className={cn(\n                              \"grid h-11 w-11 shrink-0 place-items-center rounded-sk-btn border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]\",\n                              inCart ? \"border-sk-savings/40 text-sk-savings\" : \"border-sk-line text-sk-ink hover:bg-sk-ink/[0.05]\"\n                            )}\n                          >\n                            {inCart ? <Check size={16} /> : <Plus size={16} />}\n                          </button>\n                        </div>\n                      )\n                    })}\n                  </div>\n                </div>\n              ) : null}\n            </div>\n\n            {/* Footer */}\n            <div className=\"border-t border-sk-line px-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-3\">\n              <button\n                type=\"button\"\n                aria-expanded={codeOpen}\n                aria-controls={codeRegion}\n                onClick={() => setCodeOpen((o) => !o)}\n                className=\"flex h-11 w-full items-center justify-between rounded-sk-btn text-[13px] font-medium text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent\"\n              >\n                {applied ? `Code ${applied.code} applied` : \"Add a discount code\"}\n                <ChevronDown size={16} className={cn(\"text-sk-muted transition-transform duration-200\", codeOpen && \"rotate-180\")} />\n              </button>\n              <AnimatePresence initial={false}>\n                {codeOpen ? (\n                  <motion.div\n                    id={codeRegion}\n                    initial={{ height: 0, opacity: 0 }}\n                    animate={{ height: \"auto\", opacity: 1 }}\n                    exit={{ height: 0, opacity: 0 }}\n                    transition={{ duration: 0.28 * d, ease: [0.16, 1, 0.3, 1] }}\n                    className=\"overflow-hidden\"\n                  >\n                    <form\n                      className=\"flex items-start gap-2 px-px pb-3 pt-1\"\n                      onSubmit={(e) => {\n                        e.preventDefault()\n                        applyCode()\n                      }}\n                    >\n                      <div className=\"flex-1\">\n                        <Input aria-label=\"Discount code\" placeholder=\"WELCOME10\" value={code} onChange={(e) => setCode(e.target.value)} error={codeError} />\n                      </div>\n                      <Button type=\"submit\" variant=\"outline\">Apply</Button>\n                    </form>\n                  </motion.div>\n                ) : null}\n              </AnimatePresence>\n\n              <dl className=\"grid gap-1.5 text-sm\">\n                <div className=\"flex justify-between\">\n                  <dt className=\"text-sk-muted\">Subtotal</dt>\n                  <dd className=\"tabular-nums\">{money(subtotal)}</dd>\n                </div>\n                {applied ? (\n                  <div className=\"flex justify-between text-sk-savings\">\n                    <dt>Discount ({applied.pct}%)</dt>\n                    <dd className=\"tabular-nums\">-{money(discount)}</dd>\n                  </div>\n                ) : null}\n                <div className=\"flex justify-between\">\n                  <dt className=\"text-sk-muted\">Shipping</dt>\n                  <dd className={cn(unlocked && \"text-sk-savings\")}>{unlocked ? \"Free\" : \"Calculated at checkout\"}</dd>\n                </div>\n              </dl>\n              <div className=\"mt-4 grid gap-2\">\n                <Button size=\"lg\" className=\"w-full\" disabled={!lines.length} loading={checkingOut} onClick={checkout}>\n                  <Lock size={14} />\n                  Checkout <span className=\"tabular-nums\">{money(afterDiscount)}</span>\n                </Button>\n                <Button variant=\"ghost\" className=\"w-full\" onClick={() => onOpenChange(false)}>\n                  Continue shopping\n                </Button>\n              </div>\n            </div>\n          </motion.div>\n        </div>\n      ) : null}\n    </AnimatePresence>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/cart/cart-drawer.tsx"
    }
  ],
  "docs": "Controlled by open/onOpenChange. Pass contained to pin it inside a relative parent instead of the viewport.",
  "meta": {
    "section": "cart",
    "variant": "drawer",
    "niche": [
      "any"
    ],
    "commerce": [
      "cart",
      "quantity",
      "discount",
      "shipping-threshold",
      "upsell"
    ],
    "previewHeight": 780
  }
}