{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cart","type":"registry:block","title":"Cart","description":"A cart whose receipt does the maths in front of you: codes are stamped on, a rail of light fills toward free shipping and every total rolls.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/input.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/stepper-input.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/cart.tsx","type":"registry:component","content":"// Hairline UI · cart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowRight, Tag, X } from \"lucide-react\";\nimport { Faces } from \"./faces\";\nimport { Input } from \"./input\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { StepperInput } from \"./stepper-input\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * CART: a receipt that does the maths in front of you\n *\n *   items     rows with a tile, a stepper and a line total that\n *             rolls; changes are sent once you stop pressing, and\n *             a refusal (\"Only 2 left\") puts the row back and says so\n *   remove    the row folds into \"Removed · Undo\" over a draining\n *             hairline; nothing is removed until it runs out\n *   code      the code you apply is stamped onto the cart as a\n *             chip, and the discount rolls into the receipt\n *   receipt   the lit surface: a rail of light fills toward free\n *             shipping, every line and the total roll as you go\n *\n * The cart is yours: pass items, and do the work in onQuantity-\n * Change, onRemove, onApplyCode and onCheckout. Each is one async\n * call to your API; throw an Error and its message shows where\n * the thing happened.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CartItem {\n  id: string;\n  name: string;\n  /** A line under the name, e.g. \"Sand · M\". */\n  variant?: string;\n  /** Unit price in major units. */\n  price: number;\n  quantity: number;\n  /** Most you can have, e.g. what's in stock. */\n  max?: number;\n  /** Product photo; a tinted tile with the icon or initials when missing. */\n  image?: string;\n  icon?: React.ReactNode;\n}\n\nexport interface CartDiscount {\n  code: string;\n  /** Off the subtotal, e.g. 20 for 20%. */\n  percent?: number;\n  /** A fixed amount off, in major units. */\n  amount?: number;\n}\n\nexport interface CartSummary {\n  items: { id: string; quantity: number }[];\n  code: string | null;\n  subtotal: number;\n  discount: number;\n  shipping: number;\n  total: number;\n}\n\nexport interface CartProps {\n  items: CartItem[];\n  /** The applied code, if you keep it (controlled); otherwise the cart remembers what onApplyCode returned. */\n  discount?: CartDiscount | null;\n  /** Flat shipping cost below the free-shipping threshold. */\n  shipping?: number;\n  /** Order value (after discount) that ships free; draws the rail on the receipt. */\n  freeShippingAt?: number;\n  /** Save a new quantity; throw an Error (e.g. \"Only 2 left\") to put it back. */\n  onQuantityChange?: (id: string, quantity: number) => Promise<unknown>;\n  /** Remove an item once its Undo runs out. */\n  onRemove?: (id: string) => Promise<unknown>;\n  /** Check a code and return what it's worth; throw an Error to say why not. */\n  onApplyCode?: (code: string) => Promise<CartDiscount>;\n  onRemoveCode?: () => Promise<unknown>;\n  /** Start checkout; resolve once you're redirecting. */\n  onCheckout?: (summary: CartSummary) => Promise<unknown>;\n  title?: string;\n  currency?: string;\n  locale?: string;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst UNDO_MS = 4000;\nconst SEND_AFTER = 450;\nconst NO_DISCOUNT = null;\n\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst RECEIPT: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 90% at 0% 0%, ${mix(\"var(--primary-foreground)\", 22)} 0%, transparent 55%), linear-gradient(170deg, transparent 40%, ${mix(\"black\", 24)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 36)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\n};\nconst CARD: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, var(--card), ${mix(\"var(--card)\", 95, \"var(--foreground)\")})`,\n  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix(\"var(--background)\", 85)}`,\n};\n/* The action on the lit receipt: the light colour with the brand colour as its text. */\nconst INVERSE: React.CSSProperties = { background: \"var(--primary-foreground)\", color: \"var(--primary)\", boxShadow: `inset 0 -1px 0 ${mix(\"black\", 8)}` };\n/* The rail's ruler: an engraved tick every tenth of the way, the light running over it. */\nconst TICKS: React.CSSProperties = { backgroundImage: `repeating-linear-gradient(90deg, ${mix(\"var(--primary-foreground)\", 28)} 0 1px, transparent 1px 10%)` };\n\nfunction tint(seed: string): React.CSSProperties {\n  let h = 0;\n  for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;\n  const chart = `var(--chart-${(h % 5) + 1}, var(--primary))`;\n  return { background: mix(chart, 18, \"var(--card)\"), color: mix(chart, 72, \"var(--foreground)\"), boxShadow: `inset 0 0 0 1px ${mix(chart, 24)}` };\n}\n\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase())\n    .join(\"\");\n\nconst money = (currency: string, n: number): Intl.NumberFormatOptions => ({ style: \"currency\", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 });\nconst errorOf = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\n\n/** A line that comes and goes by height, keeping its last content while it folds. */\nfunction Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {\n  return (\n    <div className=\"grid grid-cols-1\" style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"opacity 160ms\" : `grid-template-rows 420ms ${EASE}, opacity ${open ? 320 : 140}ms ${EASE}` }}>\n      <div className=\"min-h-0 overflow-clip\">{children}</div>\n    </div>\n  );\n}\n\ntype Row = { qty?: number; settled?: boolean; pending?: boolean; error?: string; removing?: boolean; gone?: boolean };\n\nexport function Cart({\n  items,\n  discount: discountProp,\n  shipping,\n  freeShippingAt,\n  onQuantityChange,\n  onRemove,\n  onApplyCode,\n  onRemoveCode,\n  onCheckout,\n  title = \"Your cart\",\n  currency = \"USD\",\n  locale,\n  className = \"\",\n}: CartProps) {\n  const reduced = useInteractionReducedMotion();\n  const baseId = React.useId();\n  const [rows, setRows] = React.useState<Record<string, Row>>({});\n  const [ready, setReady] = React.useState(false);\n  const [ownDiscount, setOwnDiscount] = React.useState<CartDiscount | null>(NO_DISCOUNT);\n  const discount = discountProp !== undefined ? discountProp : ownDiscount;\n  const [code, setCode] = React.useState(\"\");\n  const [codeStatus, setCodeStatus] = React.useState<ActionStatus>(\"idle\");\n  const [codeError, setCodeError] = React.useState<string | null>(null);\n  const [checkout, setCheckout] = React.useState<{ status: ActionStatus; error?: string }>({ status: \"idle\" });\n  const [said, setSaid] = React.useState(\"\");\n  const timers = React.useRef(new Map<string, number>());\n  const rowEls = React.useRef(new Map<string, HTMLElement>());\n  const chipRef = React.useRef<HTMLDivElement>(null);\n  const codeInputRef = React.useRef<HTMLInputElement>(null);\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setReady(true));\n    const all = timers.current;\n    return () => {\n      cancelAnimationFrame(frame);\n      all.forEach((t) => window.clearTimeout(t));\n    };\n  }, []);\n\n  // When your items change, quantities you've saved give way to what you now say.\n  const [seenItems, setSeenItems] = React.useState(items);\n  if (items !== seenItems) {\n    setSeenItems(items);\n    setRows((r) => Object.fromEntries(Object.entries(r).filter(([id, row]) => items.some((i) => i.id === id) && !row.settled)));\n  }\n\n  const patch = (id: string, next: Partial<Row> | null) =>\n    setRows((r) => {\n      const copy = { ...r };\n      if (next === null) delete copy[id];\n      else copy[id] = { ...copy[id], ...next };\n      return copy;\n    });\n\n  const qtyOf = (i: CartItem) => rows[i.id]?.qty ?? i.quantity;\n  const live = items.filter((i) => !rows[i.id]?.removing && !rows[i.id]?.gone);\n  const count = live.reduce((n, i) => n + qtyOf(i), 0);\n  const subtotal = Math.round(live.reduce((n, i) => n + i.price * qtyOf(i), 0) * 100) / 100;\n  const off = discount ? Math.min(subtotal, Math.round((discount.percent ? (subtotal * discount.percent) / 100 : (discount.amount ?? 0)) * 100) / 100) : 0;\n  const after = subtotal - off;\n  const free = freeShippingAt !== undefined && after >= freeShippingAt;\n  const ship = live.length === 0 || free ? 0 : (shipping ?? 0);\n  const total = Math.round((after + ship) * 100) / 100;\n  const progress = freeShippingAt ? Math.min(1, after / freeShippingAt) : 0;\n  const busy = checkout.status === \"pending\" || checkout.status === \"success\";\n\n  const setQty = (item: CartItem, q: number) => {\n    patch(item.id, { qty: q, settled: false, error: undefined });\n    window.clearTimeout(timers.current.get(item.id));\n    // Sent once you stop pressing, so five taps are one request.\n    timers.current.set(\n      item.id,\n      window.setTimeout(async () => {\n        if (!onQuantityChange) return patch(item.id, { settled: true });\n        patch(item.id, { pending: true });\n        try {\n          await onQuantityChange(item.id, q);\n          patch(item.id, { pending: false, settled: true });\n        } catch (e) {\n          setRows((r) => ({ ...r, [item.id]: { error: errorOf(e, \"Couldn’t update that.\") } }));\n          rowEls.current.get(item.id)?.animate([{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"none\" }], { duration: 340, easing: \"ease-out\" });\n        }\n      }, SEND_AFTER),\n    );\n  };\n\n  const remove = (item: CartItem) => {\n    window.clearTimeout(timers.current.get(item.id));\n    patch(item.id, { removing: true, error: undefined });\n    setSaid(`${item.name} removed. Undo is available for a few seconds.`);\n    timers.current.set(\n      item.id,\n      window.setTimeout(async () => {\n        patch(item.id, { gone: true });\n        try {\n          await onRemove?.(item.id);\n        } catch (e) {\n          patch(item.id, { gone: false, removing: false, error: errorOf(e, \"Couldn’t remove that.\") });\n        }\n      }, UNDO_MS),\n    );\n  };\n\n  const undo = (item: CartItem) => {\n    window.clearTimeout(timers.current.get(item.id));\n    patch(item.id, { removing: false });\n    setSaid(`${item.name} is back in your cart.`);\n    window.setTimeout(() => rowEls.current.get(item.id)?.querySelector<HTMLElement>(\"input,button\")?.focus({ preventScroll: true }), 60);\n  };\n\n  const apply = async () => {\n    const value = code.trim();\n    if (!value || codeStatus === \"pending\") return;\n    if (!onApplyCode) return;\n    setCodeStatus(\"pending\");\n    setCodeError(null);\n    try {\n      const d = await onApplyCode(value);\n      setOwnDiscount(d);\n      setCodeStatus(\"idle\");\n      setCode(\"\");\n      setSaid(`Code ${d.code} applied.`);\n    } catch (e) {\n      setCodeStatus(\"error\");\n      setCodeError(errorOf(e, \"That code didn’t work.\"));\n    }\n  };\n\n  const removeCode = async () => {\n    setOwnDiscount(null);\n    setSaid(\"Code removed.\");\n    window.setTimeout(() => codeInputRef.current?.focus({ preventScroll: true }), 80);\n    try {\n      await onRemoveCode?.();\n    } catch {\n      // The code stays off here; your server is the judge on the next checkout.\n    }\n  };\n\n  // The code is stamped on: it lands big and tilted out of a blur and is thrown down flat.\n  const stamped = discount?.code ?? null;\n  const lastStamp = React.useRef<string | null>(null);\n  React.useLayoutEffect(() => {\n    const was = lastStamp.current;\n    lastStamp.current = stamped;\n    if (!ready || !stamped || stamped === was || reduced) return;\n    chipRef.current?.animate(\n      [\n        { transform: \"scale(1.5) rotate(-9deg)\", opacity: 0, filter: \"blur(6px)\" },\n        { transform: \"scale(0.94) rotate(1.5deg)\", opacity: 1, filter: \"blur(0px)\", offset: 0.55 },\n        { transform: \"none\", opacity: 1, filter: \"blur(0px)\" },\n      ],\n      { duration: 560, easing: EASE_THROW },\n    );\n  }, [stamped, ready, reduced]);\n\n  const pay = async () => {\n    if (busy || !live.length) return;\n    setCheckout({ status: \"pending\" });\n    try {\n      await onCheckout?.({ items: live.map((i) => ({ id: i.id, quantity: qtyOf(i) })), code: discount?.code ?? null, subtotal, discount: off, shipping: ship, total });\n      setCheckout({ status: \"success\" });\n    } catch (e) {\n      setCheckout({ status: \"error\", error: errorOf(e, \"Couldn’t start checkout. Try again.\") });\n    }\n  };\n\n  const fmt = (n: number) => money(currency, n);\n\n  return (\n    <section aria-labelledby={`${baseId}-title`} className={`w-full max-w-[440px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD}>\n      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 440px)\" }} />\n      <div className=\"@container w-full\">\n        <div className=\"flex items-baseline justify-between px-3 pb-1 pt-2.5\">\n          <h2 id={`${baseId}-title`} className=\"text-[15px] font-medium tracking-[-0.01em]\">\n            {title}\n          </h2>\n          <p className=\"text-[12.5px] tabular-nums text-muted-foreground\">\n            <NumberRoll value={count} duration={600} /> <TextMorph>{count === 1 ? \"item\" : \"items\"}</TextMorph>\n          </p>\n        </div>\n\n        <Fold open={live.length === 0 && !items.some((i) => rows[i.id]?.removing)} reduced={reduced}>\n          <p className=\"px-3 py-6 text-center text-[13.5px] text-muted-foreground\">Your cart is empty.</p>\n        </Fold>\n\n        <ul className=\"px-1\">\n          {items.map((item) => {\n            const row = rows[item.id] ?? {};\n            const q = qtyOf(item);\n            return (\n              <li key={item.id} className=\"grid grid-cols-1\" style={{ gridTemplateRows: row.gone ? \"0fr\" : \"1fr\", opacity: row.gone ? 0 : 1, transition: reduced ? \"opacity 160ms\" : `grid-template-rows 380ms ${EASE} 100ms, opacity 200ms ${EASE}` }}>\n                <div className=\"min-h-0 overflow-clip\">\n                  <div\n                    ref={(el) => {\n                      if (el) rowEls.current.set(item.id, el);\n                      else rowEls.current.delete(item.id);\n                    }}\n                    className=\"border-b border-border/70 py-2\"\n                  >\n                    <Faces active={row.removing ? 1 : 0} reduced={reduced} ready={ready}>\n                      <div className=\"flex gap-3 px-2\">\n                        <span aria-hidden=\"true\" className=\"relative flex size-[52px] shrink-0 items-center justify-center overflow-hidden rounded-[14px] text-[14px] font-medium [&_svg]:size-[22px]\" style={tint(item.name)}>\n                          {item.icon ?? initials(item.name)}\n                          {item.image && (\n                            // eslint-disable-next-line @next/next/no-img-element\n                            <img src={item.image} alt=\"\" decoding=\"async\" className=\"absolute inset-0 size-full object-cover\" />\n                          )}\n                        </span>\n                        <div className=\"min-w-0 flex-1\">\n                          <div className=\"flex items-start justify-between gap-2\">\n                            <div className=\"min-w-0\">\n                              <p className=\"truncate text-[14px] font-medium leading-tight tracking-[-0.01em]\">{item.name}</p>\n                              {item.variant && <p className=\"mt-0.5 truncate text-[12px] text-muted-foreground\">{item.variant}</p>}\n                            </div>\n                            <span className=\"shrink-0 text-[14px] font-medium tabular-nums tracking-[-0.01em]\" style={{ opacity: row.pending ? 0.55 : 1, transition: \"opacity 200ms\" }}>\n                              <NumberRoll value={item.price * q} locales={locale} format={fmt(item.price * q)} duration={700} />\n                            </span>\n                          </div>\n                          <div className=\"mt-1.5 flex items-center justify-between gap-2\">\n                            <StepperInput label={`Quantity of ${item.name}`} value={q} min={1} max={item.max ?? 99} size=\"sm\" onValueChange={(v) => setQty(item, v)} />\n                            <button type=\"button\" onClick={() => remove(item)} className={`-mr-1 h-7 cursor-pointer rounded-full px-2 text-[12px] text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                              Remove\n                            </button>\n                          </div>\n                          <Fold open={!!row.error} reduced={reduced}>\n                            <p role=\"alert\" className=\"pt-1.5 text-[12px] text-red-600 dark:text-red-400\">\n                              {row.error}\n                            </p>\n                          </Fold>\n                        </div>\n                      </div>\n\n                      <div className=\"relative flex h-10 items-center justify-between gap-3 overflow-clip rounded-[14px] px-3 text-[13px]\">\n                        <span className=\"truncate text-muted-foreground\">\n                          <span className=\"text-foreground\">{item.name}</span> removed\n                        </span>\n                        <button type=\"button\" onClick={() => undo(item)} className={`-mr-1.5 h-7 shrink-0 cursor-pointer rounded-full px-2.5 font-medium text-primary hover:bg-primary/10 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                          Undo\n                        </button>\n                        {/* How long Undo has left, draining away. */}\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"absolute inset-x-3 bottom-0 h-px origin-left bg-primary/50\"\n                          style={{ transform: row.removing ? \"scaleX(0)\" : \"scaleX(1)\", transition: row.removing && !reduced ? `transform ${UNDO_MS}ms linear` : \"none\" }}\n                        />\n                      </div>\n                    </Faces>\n                  </div>\n                </div>\n              </li>\n            );\n          })}\n        </ul>\n\n        {/* The code: a field, then a chip stamped onto the cart. */}\n        <div className=\"px-2 pb-2 pt-2.5\">\n          <Faces active={discount ? 1 : 0} reduced={reduced} ready={ready}>\n            <form\n              onSubmit={(e) => {\n                e.preventDefault();\n                void apply();\n              }}\n            >\n              <Input\n                ref={codeInputRef}\n                aria-label=\"Discount code\"\n                placeholder=\"Discount code\"\n                value={code}\n                autoCapitalize=\"characters\"\n                spellCheck={false}\n                error={codeError ?? undefined}\n                leading={<Tag aria-hidden=\"true\" />}\n                onValueChange={(v) => {\n                  setCode(v);\n                  if (codeStatus === \"error\") {\n                    setCodeStatus(\"idle\");\n                    setCodeError(null);\n                  }\n                }}\n                inputClassName=\"uppercase placeholder:normal-case tracking-[0.02em]\"\n                trailing={<StatusButton type=\"submit\" size=\"sm\" variant=\"ghost\" status={codeStatus === \"error\" ? \"idle\" : codeStatus} labels={{ idle: \"Apply\", pending: \"Checking\" }} disabled={!code.trim()} className=\"mr-0.5 text-foreground disabled:opacity-40\" />}\n              />\n            </form>\n            <div className=\"flex h-9 items-center\">\n              <div ref={chipRef} className=\"inline-flex h-9 items-center gap-2 rounded-full border border-dashed border-primary/45 bg-primary/[0.06] pl-3 pr-1 text-[13px] text-primary\">\n                <Tag aria-hidden=\"true\" className=\"size-3.5\" />\n                <span className=\"font-mono text-[12.5px] font-medium tracking-[0.06em]\">{discount?.code}</span>\n                <span className=\"text-primary/75\">{discount?.percent ? `${discount.percent}% off` : discount ? \"off\" : \"\"}</span>\n                <button type=\"button\" aria-label={`Remove code ${discount?.code ?? \"\"}`} onClick={() => void removeCode()} className={`flex size-7 cursor-pointer items-center justify-center rounded-full hover:bg-primary/10 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                  <X aria-hidden=\"true\" className=\"size-3.5\" />\n                </button>\n              </div>\n            </div>\n          </Faces>\n        </div>\n\n        {/* The one lit surface: the receipt. */}\n        <div className=\"rounded-[22px] p-4 text-primary-foreground\" style={RECEIPT}>\n          {freeShippingAt !== undefined && (\n            <div className=\"mb-3.5\">\n              <div className=\"relative h-[18px] text-[12.5px]\">\n                {[false, true].map((on) => (\n                  <p\n                    key={String(on)}\n                    aria-hidden={on !== free}\n                    className=\"absolute inset-0 truncate\"\n                    style={{\n                      opacity: on === free ? 1 : 0,\n                      transform: on === free || reduced ? \"none\" : `translateY(${on ? 6 : -6}px)`,\n                      filter: on === free || reduced ? \"none\" : \"blur(3px)\",\n                      transition: reduced ? \"opacity 160ms\" : `opacity 280ms ${EASE}, transform 420ms ${EASE}, filter 280ms ${EASE}`,\n                    }}\n                  >\n                    {on ? (\n                      <span className=\"font-medium\">Free shipping unlocked</span>\n                    ) : (\n                      <span className=\"text-primary-foreground/80\">\n                        Add <NumberRoll value={Math.max(0, freeShippingAt - after)} locales={locale} format={fmt(Math.max(0, freeShippingAt - after))} duration={700} className=\"font-medium text-primary-foreground\" /> for free shipping\n                      </span>\n                    )}\n                  </p>\n                ))}\n              </div>\n              <div className=\"relative mt-2 h-2.5\" role=\"meter\" aria-label=\"Toward free shipping\" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}>\n                <span aria-hidden=\"true\" className=\"absolute inset-x-0 top-1/2 h-2.5 -translate-y-1/2\" style={TICKS} />\n                <span aria-hidden=\"true\" className=\"absolute inset-x-0 top-1/2 h-px -translate-y-1/2\" style={{ background: mix(\"var(--primary-foreground)\", 22) }} />\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute left-0 top-1/2 h-[2px] w-full origin-left -translate-y-1/2 rounded-full\"\n                  style={{ background: \"var(--primary-foreground)\", transform: `scaleX(${progress})`, transition: reduced ? \"none\" : `transform 700ms ${EASE}` }}\n                />\n                {/* The head of the light rides the right edge of a full-width layer that slides in from the left: one composited\n                    transform, and nothing past the receipt's right edge to scroll to. */}\n                <span aria-hidden=\"true\" className=\"absolute inset-0\" style={{ transform: `translateX(${(progress - 1) * 100}%)`, transition: reduced ? \"none\" : `transform 700ms ${EASE}` }}>\n                  <span\n                    className=\"absolute right-0 top-1/2 size-2.5 translate-x-1/2 -translate-y-1/2 rounded-full\"\n                    style={{ background: \"var(--primary-foreground)\", boxShadow: `0 0 0 3px ${mix(\"var(--primary-foreground)\", 22)}` }}\n                  />\n                </span>\n              </div>\n            </div>\n          )}\n\n          <dl className=\"space-y-1.5 text-[13px]\">\n            <div className=\"flex justify-between gap-3\">\n              <dt className=\"text-primary-foreground/70\">Subtotal</dt>\n              <dd className=\"tabular-nums\">\n                <NumberRoll value={subtotal} locales={locale} format={fmt(subtotal)} duration={800} />\n              </dd>\n            </div>\n            <Fold open={off > 0} reduced={reduced}>\n              <div className=\"flex justify-between gap-3\">\n                <dt className=\"text-primary-foreground/70\">\n                  Discount <span className=\"font-mono text-[11.5px] tracking-[0.06em] text-primary-foreground/85\">{discount?.code}</span>\n                </dt>\n                <dd className=\"tabular-nums\">\n                  <NumberRoll value={-off} locales={locale} format={fmt(off)} duration={800} />\n                </dd>\n              </div>\n            </Fold>\n            {(shipping !== undefined || freeShippingAt !== undefined) && (\n              <div className=\"flex justify-between gap-3\">\n                <dt className=\"text-primary-foreground/70\">Shipping</dt>\n                <dd className=\"tabular-nums\">{ship === 0 ? <TextMorph>Free</TextMorph> : <NumberRoll value={ship} locales={locale} format={fmt(ship)} duration={600} />}</dd>\n              </div>\n            )}\n          </dl>\n\n          <div className=\"mt-3 flex items-end justify-between gap-3 border-t pt-3\" style={{ borderColor: mix(\"var(--primary-foreground)\", 18) }}>\n            <span className=\"text-[13px] text-primary-foreground/70\">Total</span>\n            <span className=\"text-[clamp(30px,9cqw,36px)] font-medium leading-none tracking-[-0.04em] tabular-nums\">\n              <NumberRoll value={total} locales={locale} format={fmt(total)} duration={1000} />\n            </span>\n          </div>\n\n          <StatusButton\n            status={checkout.status}\n            labels={{ idle: \"Check out\", pending: \"Opening checkout\", success: \"Redirecting\", error: \"Try again\" }}\n            icon={<ArrowRight aria-hidden=\"true\" />}\n            disabled={!live.length}\n            onClick={() => void pay()}\n            className=\"mt-3.5 h-11 w-full text-[14.5px] hover:opacity-95 disabled:opacity-50\"\n            style={checkout.status === \"error\" ? { background: \"var(--primary-foreground)\" } : INVERSE}\n          />\n          <p role={checkout.error ? \"alert\" : undefined} className=\"mt-2 text-center text-[11.5px] text-primary-foreground/65\">\n            {checkout.error ?? \"Taxes are worked out at checkout.\"}\n          </p>\n        </div>\n\n        <span className=\"sr-only\" role=\"status\">\n          {said}\n        </span>\n      </div>\n    </section>\n  );\n}\n\nexport default Cart;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · cart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FACES: the states of one surface, in the same place\n *\n * The active face is in flow; the others are laid over it, faded\n * and nudged aside (faces before it to the left, faces after it to\n * the right), so switching reads as the surface turning to its\n * next side. The height eases between the faces' measured heights,\n * so nothing around it jumps. Used by the blocks (the wallet's\n * activity and keypad, a pricing card and its checkout, a booking's\n * times, details and confirmation).\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nexport const FACES_MORPH = 460;\n\nexport function Faces({\n  active,\n  reduced,\n  ready,\n  className = \"\",\n  children,\n}: {\n  active: number;\n  reduced: boolean;\n  /** False until the first paint, so the faces don't animate into place on mount. */\n  ready: boolean;\n  className?: string;\n  children: React.ReactNode[];\n}) {\n  const refs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const [heights, setHeights] = React.useState<number[] | null>(null);\n  const count = children.length;\n\n  React.useLayoutEffect(() => {\n    const els = refs.current.slice(0, count);\n    const observer = new ResizeObserver(() => {\n      const next = els.map((el) => el?.offsetHeight ?? 0);\n      setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next));\n    });\n    els.forEach((el) => el && observer.observe(el));\n    return () => observer.disconnect();\n  }, [count]);\n\n  const face = (i: number): React.CSSProperties => {\n    const on = i === active;\n    return {\n      opacity: on ? 1 : 0,\n      transform: on || reduced ? \"none\" : `translateX(${i < active ? -18 : 18}px)`,\n      filter: on || reduced ? \"none\" : \"blur(4px)\",\n      transition: !ready\n        ? \"none\"\n        : reduced\n          ? \"opacity 160ms ease-out\"\n          : on\n            ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`\n            : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`,\n    };\n  };\n\n  return (\n    <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : \"none\" }}>\n      {children.map((child, i) => (\n        <div\n          key={i}\n          ref={(el) => {\n            refs.current[i] = el;\n          }}\n          inert={active !== i || undefined}\n          aria-hidden={active !== i || undefined}\n          className={active === i ? \"relative\" : \"pointer-events-none absolute inset-x-0 top-0\"}\n          style={face(i)}\n        >\n          {child}\n        </div>\n      ))}\n    </div>\n  );\n}\n"},{"path":"registry/field.tsx","type":"registry:component","content":"// Hairline UI · cart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced ? \"none\" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({ id, label, aside, hint, error, required, className = \"\", children }: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label htmlFor={id} className=\"text-[13px] font-medium text-foreground\">\n              {label}\n              {required && (\n                <span aria-hidden=\"true\" className=\"ml-0.5 text-muted-foreground\">\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{aside}</span>}\n        </div>\n      )}\n      {children}\n      <Line show={!error && !!hint} id={ids.hint} tone=\"text-muted-foreground\" reduced={reduced}>\n        {hint}\n      </Line>\n      <Line show={!!error} id={ids.error} tone=\"text-red-600 dark:text-red-400\" reduced={reduced}>\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · cart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\nexport const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (duration / 1000);\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === steps ? to : value, offset: i / steps };\n  });\n}\n\n"}],"css":{"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/cart. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}