{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"checkout","type":"registry:block","title":"Checkout","description":"A one-page checkout whose steps stack into a receipt: contact, address, delivery and your payment element, then Place order turns into the order number.","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/select.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/checkout.tsx","type":"registry:component","content":"// Hairline UI · checkout · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Lock } from \"lucide-react\";\nimport { Input } from \"./input\";\nimport { NumberRoll } from \"./number-roll\";\nimport { Select } from \"./select\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * CHECKOUT: steps that stack into a receipt\n *\n *   steps     contact, shipping, delivery and payment on one\n *             surface; the step you're on is open, a rail of light\n *             runs down the numbers as you finish them\n *   stack     a finished step folds into one line under its title\n *             (\"Iris Okafor, 12 Elm Street, Leeds · Change\") with\n *             a check drawn in its number\n *   delivery  speeds show the day they arrive; the total on the\n *             lit summary rolls when you switch\n *   placed    Place order goes Placing order → your order number,\n *             and the steps lock\n *\n * Payment is your provider's: put its element (Stripe's Payment\n * Element, PayPal buttons, anything) in the `payment` slot, so this\n * block never sees card data. onPlaceOrder gets the order; resolve\n * with { number } to show it, throw an Error to say why not.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CheckoutLine {\n  id: string;\n  name: string;\n  /** e.g. \"Sand · M\". */\n  detail?: string;\n  quantity: number;\n  /** Unit price, major units. */\n  price: number;\n  image?: string;\n  icon?: React.ReactNode;\n}\n\nexport interface CheckoutAddress {\n  name: string;\n  line1: string;\n  line2: string;\n  city: string;\n  postcode: string;\n  country: string;\n}\n\nexport interface CheckoutDelivery {\n  id: string;\n  label: string;\n  /** e.g. \"Royal Mail Tracked\". */\n  detail?: string;\n  price: number;\n  /** Days from today it arrives: one number, or the earliest and latest. */\n  days: number | [number, number];\n}\n\nexport interface CheckoutOrder {\n  email: string;\n  address: CheckoutAddress;\n  delivery: CheckoutDelivery;\n  lines: CheckoutLine[];\n  subtotal: number;\n  discount: number;\n  shipping: number;\n  tax: number;\n  total: number;\n}\n\nexport interface CheckoutProps {\n  lines: CheckoutLine[];\n  deliveries: CheckoutDelivery[];\n  /** Off the subtotal, major units. */\n  discount?: number;\n  /** Tax rate on the goods and delivery, e.g. 0.2 for 20%. */\n  taxRate?: number;\n  countries?: { value: string; label: string }[];\n  defaultEmail?: string;\n  defaultAddress?: Partial<CheckoutAddress>;\n  /** Your payment provider's element, shown in the payment step. */\n  payment?: React.ReactNode;\n  /** Place the order; resolve with { number } to show it, throw an Error to say why not. */\n  onPlaceOrder: (order: CheckoutOrder) => Promise<unknown>;\n  currency?: string;\n  locale?: string;\n  className?: string;\n}\n\ntype Step = \"contact\" | \"shipping\" | \"delivery\" | \"payment\";\nconst STEPS: { id: Step; title: string }[] = [\n  { id: \"contact\", title: \"Contact\" },\n  { id: \"shipping\", title: \"Shipping address\" },\n  { id: \"delivery\", title: \"Delivery\" },\n  { id: \"payment\", title: \"Payment\" },\n];\nconst COUNTRIES = [\n  { value: \"US\", label: \"United States\" },\n  { value: \"GB\", label: \"United Kingdom\" },\n  { value: \"CA\", label: \"Canada\" },\n  { value: \"DE\", label: \"Germany\" },\n  { value: \"FR\", label: \"France\" },\n  { value: \"AU\", label: \"Australia\" },\n  { value: \"IN\", label: \"India\" },\n];\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DAY = 86_400_000;\n\nconst subscribeNothing = () => () => {};\n/** False on the server and while hydrating: text is formatted in one fixed locale until then, so the server's and the visitor's never disagree. */\nconst useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst SUMMARY: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 70% at 100% 0%, ${mix(\"var(--primary-foreground)\", 22)} 0%, transparent 55%), linear-gradient(175deg, 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};\nconst INVERSE: React.CSSProperties = { background: \"var(--primary-foreground)\", color: \"var(--primary)\", boxShadow: `inset 0 -1px 0 ${mix(\"black\", 8)}` };\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, 22, \"var(--primary-foreground)\"), color: mix(chart, 80, \"black\") };\n}\n\nconst money = (currency: string, n: number): Intl.NumberFormatOptions => ({ style: \"currency\", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 });\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/** A fold that keeps its children laid out, so its height eases open and shut. */\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 440ms ${EASE}, opacity ${open ? 320 : 140}ms ${EASE} ${open ? 80 : 0}ms` }}>\n      <div className=\"min-h-0 overflow-clip\" inert={!open || undefined}>\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport function Checkout({\n  lines,\n  deliveries,\n  discount = 0,\n  taxRate = 0,\n  countries = COUNTRIES,\n  defaultEmail = \"\",\n  defaultAddress,\n  payment,\n  onPlaceOrder,\n  currency = \"USD\",\n  locale,\n  className = \"\",\n}: CheckoutProps) {\n  const reduced = useInteractionReducedMotion();\n  const hydrated = useHydrated();\n  const baseId = React.useId();\n  const [step, setStep] = React.useState<Step>(\"contact\");\n  const [done, setDone] = React.useState<ReadonlySet<Step>>(() => new Set());\n  const [email, setEmail] = React.useState(defaultEmail);\n  const [address, setAddress] = React.useState<CheckoutAddress>({ name: \"\", line1: \"\", line2: \"\", city: \"\", postcode: \"\", country: countries[0]?.value ?? \"\", ...defaultAddress });\n  const [deliveryId, setDeliveryId] = React.useState<string | null>(null);\n  const [errors, setErrors] = React.useState<Partial<Record<keyof CheckoutAddress | \"email\", string>>>({});\n  const [place, setPlace] = React.useState<{ status: ActionStatus; number?: string; error?: string }>({ status: \"idle\" });\n  const [today, setToday] = React.useState<number | null>(null);\n  const [said, setSaid] = React.useState(\"\");\n  const bodies = React.useRef(new Map<Step, HTMLElement>());\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setToday(Date.now()));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const delivery = deliveries.find((d) => d.id === deliveryId) ?? null;\n  const subtotal = round(lines.reduce((n, l) => n + l.price * l.quantity, 0));\n  const off = Math.min(discount, subtotal);\n  const shipping = delivery?.price ?? 0;\n  const tax = round((subtotal - off + shipping) * taxRate);\n  const total = round(subtotal - off + shipping + tax);\n  const placed = place.status === \"success\";\n  const busy = place.status === \"pending\" || placed;\n  const index = STEPS.findIndex((s) => s.id === step);\n  const ready = done.has(\"contact\") && done.has(\"shipping\") && done.has(\"delivery\");\n\n  const fmt = (n: number) => money(currency, n);\n  const loc = locale ?? (hydrated ? undefined : \"en-US\");\n  const cash = (n: number) => new Intl.NumberFormat(loc, fmt(n)).format(n);\n  const arrives = (d: CheckoutDelivery) => {\n    if (today === null) return \"\";\n    const [a, b] = Array.isArray(d.days) ? d.days : [d.days, d.days];\n    const day = new Intl.DateTimeFormat(loc, { weekday: \"short\", day: \"numeric\", month: \"short\" });\n    return a === b ? `Arrives ${day.format(today + a * DAY)}` : `Arrives ${day.formatRange(today + a * DAY, today + b * DAY)}`;\n  };\n  const country = countries.find((c) => c.value === address.country)?.label ?? address.country;\n  const summaries: Record<Step, string> = {\n    contact: email,\n    shipping: [address.name, address.line1, address.line2, `${address.city} ${address.postcode}`.trim(), country].filter(Boolean).join(\", \"),\n    delivery: delivery ? `${delivery.label} · ${delivery.price ? cash(delivery.price) : \"Free\"}` : \"\",\n    payment: \"\",\n  };\n\n  const open = (s: Step) => {\n    if (busy) return;\n    setStep(s);\n    window.setTimeout(() => bodies.current.get(s)?.querySelector<HTMLElement>(\"input,button[role=radio],button\")?.focus({ preventScroll: true }), reduced ? 0 : 320);\n  };\n\n  const finish = (s: Step) => {\n    if (s === \"contact\") {\n      const problem = EMAIL.test(email.trim()) ? undefined : email.trim() ? \"That email doesn’t look right\" : \"Add your email\";\n      setErrors({ email: problem });\n      if (problem) return;\n    }\n    if (s === \"shipping\") {\n      const need: (keyof CheckoutAddress)[] = [\"name\", \"line1\", \"city\", \"postcode\"];\n      const problems = Object.fromEntries(need.filter((k) => !address[k].trim()).map((k) => [k, \"Required\"]));\n      setErrors(problems);\n      if (Object.keys(problems).length) return;\n    }\n    if (s === \"delivery\" && !delivery) return;\n    setDone((d) => new Set([...d, s]));\n    const next = STEPS.find((x, i) => i > STEPS.findIndex((y) => y.id === s) && !done.has(x.id))?.id ?? \"payment\";\n    setSaid(`${STEPS.find((x) => x.id === s)?.title} saved.`);\n    open(next);\n  };\n\n  const placeOrder = async () => {\n    if (!ready || !delivery || busy) return;\n    setPlace({ status: \"pending\" });\n    try {\n      const result = await onPlaceOrder({ email: email.trim(), address, delivery, lines, subtotal, discount: off, shipping, tax, total });\n      const number = result && typeof result === \"object\" && \"number\" in result ? String((result as { number: unknown }).number) : undefined;\n      setPlace({ status: \"success\", number });\n      setSaid(number ? `Order ${number} placed.` : \"Order placed.\");\n    } catch (e) {\n      setPlace({ status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t place the order. Try again.\" });\n    }\n  };\n\n  const field = (k: keyof CheckoutAddress, label: string, autoComplete: string, extra?: Partial<React.ComponentProps<typeof Input>>) => (\n    <Input\n      label={label}\n      autoComplete={autoComplete}\n      value={address[k]}\n      error={errors[k]}\n      onValueChange={(v) => {\n        setAddress((a) => ({ ...a, [k]: v }));\n        if (errors[k]) setErrors((e) => ({ ...e, [k]: undefined }));\n      }}\n      {...extra}\n    />\n  );\n\n  const next = `inline-flex h-10 cursor-pointer items-center justify-center rounded-full bg-primary px-5 text-[13.5px] font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-default disabled:opacity-45 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n\n  return (\n    <section aria-labelledby={`${baseId}-title`} className={`w-full max-w-[860px] 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, 860px)\" }} />\n      <div className=\"@container w-full\">\n        <h2 id={`${baseId}-title`} className=\"sr-only\">\n          Checkout\n        </h2>\n        <div className=\"grid grid-cols-1 gap-2 @[700px]:grid-cols-[minmax(0,1fr)_320px] @[700px]:items-start\">\n          {/* Flat: the steps, finished ones stacked as one line each. */}\n          <ol className=\"relative px-2 py-1\" inert={placed || undefined}>\n            {/* The rail of light down the numbers, as far as you've got. */}\n            <span aria-hidden=\"true\" className=\"absolute bottom-6 left-[19.5px] top-6 w-px bg-border\" />\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute left-[19.5px] top-6 w-px origin-top bg-primary\"\n              style={{ height: \"calc(100% - 48px)\", transform: `scaleY(${Math.min(done.size, STEPS.length - 1) / (STEPS.length - 1)})`, transition: reduced ? \"none\" : `transform 620ms ${EASE}` }}\n            />\n            {STEPS.map((s, i) => {\n              const isOn = s.id === step;\n              const isDone = done.has(s.id) && !isOn;\n              const reachable = isDone || i <= index || STEPS.slice(0, i).every((p) => done.has(p.id));\n              return (\n                <li key={s.id} className=\"relative py-2.5 pl-9\">\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"absolute left-0 top-[13px] flex size-6 items-center justify-center rounded-full text-[11.5px] font-medium tabular-nums\"\n                    style={{\n                      background: isDone ? \"var(--primary)\" : \"var(--card)\",\n                      color: isDone ? \"var(--primary-foreground)\" : isOn ? \"var(--primary)\" : \"var(--muted-foreground)\",\n                      boxShadow: isDone ? \"none\" : `inset 0 0 0 ${isOn ? 1.5 : 1}px ${isOn ? \"var(--primary)\" : \"var(--border)\"}`,\n                      transition: reduced ? \"none\" : `background-color 300ms ${EASE}, box-shadow 300ms ${EASE}, color 300ms`,\n                    }}\n                  >\n                    {isDone ? (\n                      <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n                        <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeDasharray={24} className=\"motion-safe:animate-[ui-draw_420ms_cubic-bezier(0.16,1,0.3,1)_120ms_both]\" />\n                      </svg>\n                    ) : (\n                      i + 1\n                    )}\n                  </span>\n                  <div className=\"flex min-h-7 items-center justify-between gap-3\">\n                    <h3 className={`text-[14.5px] font-medium tracking-[-0.01em] ${isOn || isDone ? \"text-foreground\" : \"text-muted-foreground\"}`}>{s.title}</h3>\n                    {isDone && !placed && (\n                      <button type=\"button\" onClick={() => open(s.id)} aria-label={`Change ${s.title.toLowerCase()}`} className={`-mr-1.5 h-7 cursor-pointer rounded-full px-2.5 text-[12.5px] text-primary hover:bg-primary/10 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                        Change\n                      </button>\n                    )}\n                  </div>\n                  {/* The finished step, as a line of the receipt. */}\n                  <Fold open={isDone && Boolean(summaries[s.id])} reduced={reduced}>\n                    <p className=\"truncate pb-0.5 text-[13px] text-muted-foreground\">{summaries[s.id]}</p>\n                  </Fold>\n                  <Fold open={isOn} reduced={reduced}>\n                    <div\n                      ref={(el) => {\n                        if (el) bodies.current.set(s.id, el);\n                      }}\n                      className=\"pb-1 pt-3\"\n                    >\n                      {s.id === \"contact\" && (\n                        <form\n                          noValidate\n                          className=\"flex flex-col gap-3 @[480px]:flex-row @[480px]:items-start\"\n                          onSubmit={(e) => {\n                            e.preventDefault();\n                            finish(\"contact\");\n                          }}\n                        >\n                          <Input\n                            aria-label=\"Email\"\n                            type=\"email\"\n                            inputMode=\"email\"\n                            autoComplete=\"email\"\n                            placeholder=\"you@email.com\"\n                            value={email}\n                            error={errors.email}\n                            onValueChange={(v) => {\n                              setEmail(v);\n                              if (errors.email) setErrors((e) => ({ ...e, email: undefined }));\n                            }}\n                            className=\"@[480px]:flex-1\"\n                          />\n                          <button type=\"submit\" className={next}>\n                            Continue\n                          </button>\n                        </form>\n                      )}\n                      {s.id === \"shipping\" && (\n                        <form\n                          noValidate\n                          className=\"grid grid-cols-1 gap-3 @[480px]:grid-cols-2\"\n                          onSubmit={(e) => {\n                            e.preventDefault();\n                            finish(\"shipping\");\n                          }}\n                        >\n                          <div className=\"@[480px]:col-span-2\">{field(\"name\", \"Full name\", \"name\")}</div>\n                          <div className=\"@[480px]:col-span-2\">{field(\"line1\", \"Address\", \"address-line1\")}</div>\n                          <div className=\"@[480px]:col-span-2\">{field(\"line2\", \"Apartment, suite (optional)\", \"address-line2\")}</div>\n                          {field(\"city\", \"City\", \"address-level2\")}\n                          {field(\"postcode\", \"Postcode\", \"postal-code\")}\n                          <div className=\"@[480px]:col-span-2\">\n                            <Select label=\"Country\" options={countries} value={address.country} onValueChange={(v) => setAddress((a) => ({ ...a, country: v }))} />\n                          </div>\n                          <div className=\"@[480px]:col-span-2\">\n                            <button type=\"submit\" className={next}>\n                              Continue\n                            </button>\n                          </div>\n                        </form>\n                      )}\n                      {s.id === \"delivery\" && (\n                        <div>\n                          <div role=\"radiogroup\" aria-label=\"Delivery speed\" className=\"overflow-clip rounded-[18px] border border-border\">\n                            {deliveries.map((d) => {\n                              const on = d.id === deliveryId;\n                              return (\n                                <button\n                                  key={d.id}\n                                  type=\"button\"\n                                  role=\"radio\"\n                                  aria-checked={on}\n                                  onClick={() => setDeliveryId(d.id)}\n                                  onKeyDown={(e) => {\n                                    const i2 = deliveries.indexOf(d) + (e.key === \"ArrowDown\" || e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowUp\" || e.key === \"ArrowLeft\" ? -1 : 0);\n                                    if (i2 === deliveries.indexOf(d) || !deliveries[i2]) return;\n                                    e.preventDefault();\n                                    setDeliveryId(deliveries[i2].id);\n                                    (e.currentTarget.parentElement?.children[i2] as HTMLElement | undefined)?.focus();\n                                  }}\n                                  tabIndex={on || (!deliveryId && d === deliveries[0]) ? 0 : -1}\n                                  className={`flex w-full cursor-pointer items-center gap-3 border-t border-border px-3.5 py-2.5 text-left first:border-t-0 ${on ? \"bg-primary/[0.05]\" : \"hover:bg-accent/60\"} ${INTERACTION_FOCUS} focus-visible:ring-inset focus-visible:ring-offset-0`}\n                                >\n                                  <span aria-hidden=\"true\" className=\"flex size-[18px] shrink-0 items-center justify-center rounded-full\" style={{ boxShadow: `inset 0 0 0 ${on ? 5 : 1.5}px ${on ? \"var(--primary)\" : mix(\"var(--foreground)\", 22, \"var(--border)\")}`, transition: reduced ? \"none\" : `box-shadow 260ms ${EASE_THROW}` }} />\n                                  <span className=\"min-w-0 flex-1\">\n                                    <span className=\"block text-[13.5px] font-medium text-foreground\">{d.label}</span>\n                                    <span className=\"block text-[12px] leading-snug text-muted-foreground\">\n                                      {arrives(d)}\n                                      {d.detail ? ` · ${d.detail}` : \"\"}\n                                    </span>\n                                  </span>\n                                  <span className=\"shrink-0 text-[13.5px] tabular-nums text-foreground\">{d.price ? cash(d.price) : \"Free\"}</span>\n                                </button>\n                              );\n                            })}\n                          </div>\n                          <button type=\"button\" disabled={!delivery} onClick={() => finish(\"delivery\")} className={`${next} mt-3`}>\n                            Continue\n                          </button>\n                        </div>\n                      )}\n                      {s.id === \"payment\" && (\n                        <div className=\"text-[13px] text-muted-foreground\">\n                          {payment ?? <p>You’ll pay on the next page.</p>}\n                          <p className=\"mt-2.5 inline-flex items-center gap-1.5 text-[12px]\">\n                            <Lock aria-hidden=\"true\" className=\"size-3\" />\n                            {reachable && ready ? \"Place your order from the summary.\" : \"Finish the steps above first.\"}\n                          </p>\n                        </div>\n                      )}\n                    </div>\n                  </Fold>\n                </li>\n              );\n            })}\n          </ol>\n\n          {/* The one lit surface: the summary, with the order's main action. */}\n          <div className=\"rounded-[22px] p-4 text-primary-foreground\" style={SUMMARY}>\n            <div className=\"flex items-baseline justify-between\">\n              <p className=\"text-[13.5px] font-medium\">Order summary</p>\n              <p className=\"text-[12px] text-primary-foreground/70\">\n                {lines.reduce((n, l) => n + l.quantity, 0)} items\n              </p>\n            </div>\n            <ul className=\"mt-3 space-y-2.5\">\n              {lines.map((l) => (\n                <li key={l.id} className=\"flex items-center gap-2.5 text-[13px]\">\n                  {/* The tile clips its photo; the quantity sits on its corner, outside the clip. */}\n                  <span aria-hidden=\"true\" className=\"relative shrink-0\">\n                    <span className=\"relative flex size-9 items-center justify-center overflow-hidden rounded-[10px] text-[12px] font-medium [&_svg]:size-[18px]\" style={tint(l.name)}>\n                      {l.icon ?? l.name.slice(0, 1)}\n                      {l.image && (\n                        // eslint-disable-next-line @next/next/no-img-element\n                        <img src={l.image} alt=\"\" decoding=\"async\" className=\"absolute inset-0 size-full object-cover\" />\n                      )}\n                    </span>\n                    {l.quantity > 1 && (\n                      <span\n                        className=\"absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 text-[10.5px] font-medium tabular-nums\"\n                        style={{ background: \"var(--primary-foreground)\", color: \"var(--primary)\", boxShadow: \"0 0 0 2px var(--primary)\" }}\n                      >\n                        {l.quantity}\n                      </span>\n                    )}\n                  </span>\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block truncate font-medium\">{l.name}</span>\n                    {l.detail && <span className=\"block truncate text-[11.5px] text-primary-foreground/65\">{l.detail}</span>}\n                  </span>\n                  <span className=\"shrink-0 tabular-nums\">{cash(l.price * l.quantity)}</span>\n                </li>\n              ))}\n            </ul>\n\n            <dl className=\"mt-3.5 space-y-1.5 border-t pt-3 text-[13px]\" style={{ borderColor: mix(\"var(--primary-foreground)\", 18) }}>\n              <div className=\"flex justify-between gap-3\">\n                <dt className=\"text-primary-foreground/70\">Subtotal</dt>\n                <dd className=\"tabular-nums\">{cash(subtotal)}</dd>\n              </div>\n              {off > 0 && (\n                <div className=\"flex justify-between gap-3\">\n                  <dt className=\"text-primary-foreground/70\">Discount</dt>\n                  <dd className=\"tabular-nums\">−{cash(off)}</dd>\n                </div>\n              )}\n              <div className=\"flex justify-between gap-3\">\n                <dt className=\"text-primary-foreground/70\">Delivery</dt>\n                <dd className=\"tabular-nums\">{!delivery ? <span className=\"text-primary-foreground/60\">Next step</span> : shipping ? <NumberRoll value={shipping} locales={locale} format={fmt(shipping)} duration={700} /> : <TextMorph>Free</TextMorph>}</dd>\n              </div>\n              {taxRate > 0 && (\n                <div className=\"flex justify-between gap-3\">\n                  <dt className=\"text-primary-foreground/70\">Tax</dt>\n                  <dd className=\"tabular-nums\">\n                    <NumberRoll value={tax} locales={locale} format={fmt(tax)} duration={700} />\n                  </dd>\n                </div>\n              )}\n            </dl>\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-[32px] 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={place.status}\n              labels={{ idle: \"Place order\", pending: \"Placing order\", success: place.number ? `Order ${place.number}` : \"Order placed\", error: \"Try again\" }}\n              disabled={!ready && place.status === \"idle\"}\n              onClick={() => void placeOrder()}\n              className=\"mt-3.5 h-11 w-full text-[14.5px] hover:opacity-95 disabled:opacity-50\"\n              style={place.status === \"error\" ? { background: \"var(--primary-foreground)\" } : INVERSE}\n            />\n            <p role={place.error ? \"alert\" : undefined} className=\"mt-2 text-center text-[11.5px] text-primary-foreground/70\">\n              {place.error ?? (placed ? `A receipt is on its way to ${email.trim()}.` : ready ? \"You won’t be charged until you place it.\" : \"Finish the steps to place your order.\")}\n            </p>\n          </div>\n        </div>\n\n        <span className=\"sr-only\" role=\"status\">\n          {said}\n        </span>\n      </div>\n    </section>\n  );\n}\n\nexport default Checkout;\n"},{"path":"registry/field.tsx","type":"registry:component","content":"// Hairline UI · checkout · 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/pill-select.tsx","type":"registry:component","content":"// Hairline UI · checkout · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * PILL SELECT: a select that's the same surface as its pill\n *\n *   open      the pill grows into its list (size and corners\n *             ease) from the edge it's lined up with, while its\n *             label fades into the options; it opens upward when\n *             there's no room below, and long lists scroll\n *   pick      the list folds back into the pill and the label\n *             morphs to the new choice; the current option\n *             carries a drawn check\n *   keys      arrows move a highlight that glides between rows,\n *             Home and End jump, typing a few letters finds an\n *             option, Enter picks, Escape folds it back\n *\n * The list lives in a portal on the page's body, placed over\n * the pill, so a clipping toolbar, fold or scroller never cuts\n * it off; it carries the theme's colours with it. Scrolling\n * the page closes it.\n *\n * Used wherever a component needs a choice from a list; never\n * the browser's own dropdown, which can't follow the theme.\n * ───────────────────────────────────────────────────────── */\n\nexport interface PillOption {\n  value: string;\n  label: string;\n  /** A second line in the list, e.g. a sample value. */\n  description?: string;\n}\n\nexport interface PillSelectProps {\n  options: PillOption[];\n  value: string | null | undefined;\n  onValueChange: (value: string) => void;\n  /** Accessible name, e.g. \"Column for Email\". */\n  label: string;\n  /** Shown while nothing is chosen. */\n  placeholder?: string;\n  /** What the pill shows for a choice; the option's label by default. */\n  display?: (option: PillOption | undefined) => string;\n  /** The pill's contents, when it needs more than text (an icon that turns). */\n  renderValue?: (option: PillOption | undefined, open: boolean) => React.ReactNode;\n  /** Monospace labels, for code-like values (event names, column headers). */\n  mono?: boolean;\n  /** Which edge of the pill the list lines up with. */\n  align?: \"start\" | \"end\";\n  /** The list's width; never narrower than the pill. */\n  width?: number;\n  /** Fill the width it's given. */\n  block?: boolean;\n  /** Dims the pill's text, e.g. for \"Don't import\". */\n  muted?: boolean;\n  disabled?: boolean;\n  /** \"sm\" is the 28px pill for toolbars; \"md\" is 36px, the height of the other form fields. */\n  size?: \"sm\" | \"md\";\n  className?: string;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DURATION = 420;\nconst MAX_LIST = 280;\nconst PILL_SIZE = { sm: \"h-7 px-2.5 text-[12px]\", md: \"h-9 pl-4 pr-3.5 text-[13.5px]\" };\n// The theme travels with the list, so it matches the pill wherever the pill's colours were set.\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\"];\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Chevron({ up, reduced }: { up: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" style={{ transform: up ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}>\n      <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport function PillSelect({\n  options,\n  value,\n  onValueChange,\n  label,\n  placeholder = \"Choose\",\n  display,\n  renderValue,\n  mono = false,\n  align = \"start\",\n  width = 240,\n  block = false,\n  muted = false,\n  disabled = false,\n  size = \"sm\",\n  className = \"\",\n}: PillSelectProps) {\n  const id = React.useId();\n  const reduced = useReducedMotion();\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [active, setActive] = React.useState(0);\n  const [pill, setPill] = React.useState({ width: 0, height: 28 });\n  const [panelHeight, setPanelHeight] = React.useState(0);\n  const [fit, setFit] = React.useState({ up: false, width, shift: 0, x: 0, y: 0, theme: {} as Record<string, string> });\n  const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false });\n  const [quiet, setQuiet] = React.useState(false);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const timer = React.useRef(0);\n  const typed = React.useRef({ text: \"\", at: 0 });\n  const selected = options.find((o) => o.value === value);\n  const expanded = phase === \"open\";\n  const mounted = phase !== \"closed\";\n  const [prevPhase, setPrevPhase] = React.useState(phase);\n  if (phase !== prevPhase) {\n    setPrevPhase(phase);\n    if (phase === \"closed\") {\n      setHighlight({ top: 0, height: 0, ready: false });\n    }\n  }\n  const text = display ? display(selected) : (selected?.label ?? placeholder);\n\n  const measureFit = () => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const rect = wrap.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const w = Math.min(Math.max(width, rect.width), vw - 24);\n    // Lined up with the pill's edge, nudged back on screen.\n    const shift = align === \"start\" ? Math.min(0, vw - 12 - (rect.left + w)) : Math.max(0, 12 - (rect.right - w));\n    const estimate = Math.min(MAX_LIST, options.length * (options.some((o) => o.description) ? 46 : 32)) + 12;\n    const vh = window.innerHeight;\n    const below = vh - 8 - rect.top;\n    const above = rect.bottom - 8;\n    const up = below < estimate + 8 && above > below;\n    // Fixed coordinates of the pill's corner the list grows from.\n    const x = align === \"start\" ? rect.left : vw - rect.right;\n    const y = up ? vh - rect.bottom : rect.top;\n    const cs = getComputedStyle(wrap);\n    const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily };\n    for (const t of TOKENS) {\n      const v = cs.getPropertyValue(t);\n      if (v) theme[t] = v;\n    }\n    setFit({ up, width: w, shift, x, y, theme });\n  };\n\n  const open = () => {\n    if (disabled || phase === \"open\" || phase === \"opening\") return;\n    window.clearTimeout(timer.current);\n    measureFit();\n    setActive(Math.max(0, options.findIndex((o) => o.value === value)));\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) triggerRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), DURATION);\n    },\n    [reduced],\n  );\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useLayoutEffect(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) return;\n    const measure = () => setPill((p) => (p.width === trigger.offsetWidth && p.height === trigger.offsetHeight ? p : { width: trigger.offsetWidth, height: trigger.offsetHeight }));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(trigger);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (panel) setPanelHeight(panel.offsetHeight);\n  }, [mounted, options]);\n\n  // The highlight glides to the active row, and the list scrolls to keep it in view.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    const list = listRef.current;\n    if (!row || !list) return;\n    setHighlight((h) => (h.top === row.offsetTop && h.height === row.offsetHeight && h.ready === (h.ready || expanded) ? h : { top: row.offsetTop, height: row.offsetHeight, ready: h.ready || expanded }));\n    const scroller = list.parentElement;\n    if (scroller && scroller.scrollHeight > scroller.clientHeight) {\n      if (row.offsetTop < scroller.scrollTop) scroller.scrollTop = row.offsetTop - 4;\n      else if (row.offsetTop + row.offsetHeight > scroller.scrollTop + scroller.clientHeight) scroller.scrollTop = row.offsetTop + row.offsetHeight - scroller.clientHeight + 4;\n    }\n  }, [active, expanded, mounted]);\n\n  React.useEffect(() => {\n    if (expanded) listRef.current?.focus({ preventScroll: true });\n  }, [expanded]);\n\n  React.useEffect(() => {\n    if (!expanded) return;\n    const inside = (n: Node) => Boolean(wrapRef.current?.contains(n) || surfaceRef.current?.contains(n));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close(false);\n    };\n    // The list sits where the pill was; if the page moves under it, it goes at once rather than fold in the wrong place.\n    const gone = () => {\n      window.clearTimeout(timer.current);\n      setPhase(\"closed\");\n    };\n    const onScroll = (e: Event) => {\n      if (!surfaceRef.current?.contains(e.target as Node)) gone();\n    };\n    const onResize = gone;\n    document.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [expanded, close]);\n\n  const choose = (o: PillOption) => {\n    close();\n    if (o.value !== value) onValueChange(o.value);\n  };\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    const last = options.length - 1;\n    const move = (to: number) => {\n      e.preventDefault();\n      setActive(Math.max(0, Math.min(last, to)));\n    };\n    if (e.key === \"ArrowDown\") move(active === last ? 0 : active + 1);\n    else if (e.key === \"ArrowUp\") move(active === 0 ? last : active - 1);\n    else if (e.key === \"Home\") move(0);\n    else if (e.key === \"End\") move(last);\n    else if (e.key === \"PageDown\") move(active + 6);\n    else if (e.key === \"PageUp\") move(active - 6);\n    else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (options[active]) choose(options[active]);\n    } else if (e.key === \"Tab\") close(false);\n    else if (e.key.length === 1 && /\\S/.test(e.key)) {\n      // A few letters typed quickly find the option that starts with them.\n      const now = performance.now();\n      typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : \"\") + e.key.toLowerCase(), at: now };\n      const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text));\n      if (hit !== -1) setActive(hit);\n    }\n  };\n\n  const w = expanded ? fit.width : pill.width;\n  const h = expanded ? panelHeight : pill.height;\n  const listId = `${id}-list`;\n  const pillText = renderValue ? null : (\n    <>\n      <span className={`min-w-0 flex-1 truncate text-left ${mono ? \"font-mono text-[11.5px]\" : \"\"} ${muted || !selected ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n        {block ? text : <TextMorph>{text}</TextMorph>}\n      </span>\n      <Chevron up={expanded} reduced={reduced} />\n    </>\n  );\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative ${block ? \"flex w-full min-w-0\" : \"inline-flex\"} ${className}`}\n      onKeyDown={(e) => {\n        setQuiet(false);\n        if (e.key === \"Escape\" && expanded) {\n          e.preventDefault();\n          e.stopPropagation();\n          close();\n        }\n      }}\n      onBlur={(e) => {\n        const to = e.relatedTarget as Node | null;\n        if (expanded && document.hasFocus() && !(to && (wrapRef.current?.contains(to) || surfaceRef.current?.contains(to)))) close(false);\n      }}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={expanded}\n        aria-controls={mounted ? listId : undefined}\n        aria-label={`${label}: ${selected?.label ?? placeholder}`}\n        disabled={disabled}\n        onClick={() => (expanded ? close() : open())}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            open();\n          }\n        }}\n        onPointerDown={() => setQuiet(true)}\n        className={`inline-flex ${PILL_SIZE[size]} min-w-0 cursor-pointer items-center gap-1.5 rounded-full font-medium whitespace-nowrap shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-150 hover:bg-accent disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent ${block ? \"w-full\" : \"\"} ${quiet ? \"outline-none\" : FOCUS}`}\n      >\n        {renderValue ? renderValue(selected, expanded) : pillText}\n      </button>\n\n      {mounted &&\n        createPortal(\n        <div\n          ref={surfaceRef}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              e.preventDefault();\n              e.stopPropagation();\n              close();\n            }\n          }}\n          onPointerDown={(e) => {\n            if (!(e.target as HTMLElement).closest(\"[data-scroll]\")) e.preventDefault();\n            setQuiet(true);\n          }}\n          className=\"fixed z-[60] overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            ...fit.theme,\n            [fit.up ? \"bottom\" : \"top\"]: fit.y,\n            [align === \"start\" ? \"left\" : \"right\"]: fit.x + (expanded ? (align === \"start\" ? fit.shift : -fit.shift) : 0),\n            width: w,\n            height: h,\n            borderRadius: expanded ? 16 : pill.height / 2,\n            // The pill's own hairline, the whole way: it is the same surface.\n            boxShadow: \"inset 0 0 0 1px var(--border)\",\n            transition: reduced ? \"none\" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n          }}\n        >\n          {/* The pill's label, fading out as the surface grows and back in as it folds. */}\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute inline-flex ${PILL_SIZE[size]} items-center gap-1.5 font-medium whitespace-nowrap ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n            style={{ width: pill.width, opacity: expanded ? 0 : 1, transition: reduced ? \"none\" : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms` }}\n          >\n            {renderValue ? renderValue(selected, expanded) : pillText}\n          </span>\n          <div\n            ref={panelRef}\n            className={`absolute p-1 ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n            style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? \"none\" : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms` }}\n          >\n            <div data-scroll=\"\" className=\"overflow-y-auto overscroll-contain\" style={{ maxHeight: MAX_LIST }}>\n              <div\n                ref={listRef}\n                id={listId}\n                role=\"listbox\"\n                tabIndex={-1}\n                aria-label={label}\n                aria-activedescendant={`${id}-o${active}`}\n                onKeyDown={onListKey}\n                className=\"relative outline-none\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent\"\n                  style={{\n                    height: highlight.height,\n                    transform: `translateY(${highlight.top}px)`,\n                    opacity: highlight.ready ? 1 : 0,\n                    transition: highlight.ready && !reduced ? `transform 260ms ${EASE}, height 260ms ${EASE}, opacity 200ms` : \"none\",\n                  }}\n                />\n                {options.map((o, i) => {\n                  const on = o.value === value;\n                  return (\n                    <div\n                      key={o.value}\n                      ref={(el) => {\n                        rowRefs.current[i] = el;\n                      }}\n                      id={`${id}-o${i}`}\n                      role=\"option\"\n                      aria-selected={on}\n                      onPointerMove={() => setActive(i)}\n                      onClick={() => choose(o)}\n                      className=\"relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-1.5\"\n                    >\n                      <span className=\"min-w-0 flex-1\">\n                        <span className={`block truncate ${mono ? \"font-mono text-[12px]\" : \"text-[12.5px] font-medium\"}`}>{o.label}</span>\n                        {o.description && <span className=\"mt-0.5 block truncate text-[11.5px] text-muted-foreground\">{o.description}</span>}\n                      </span>\n                      <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\">\n                        <path\n                          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.8\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          pathLength={1}\n                          strokeDasharray={1}\n                          style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 380ms ${EASE} 160ms` }}\n                        />\n                      </svg>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          </div>\n        </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · checkout · 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"}},"@keyframes ui-draw":{"from":{"stroke-dashoffset":"24"},"to":{"stroke-dashoffset":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/checkout. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}