{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"subscription","type":"registry:block","title":"Subscription","description":"A billing page in one card: the plan with a ring through its billing period, usage, card and invoices, and a cancel that offers a pause first.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/segmented-control.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/textarea.json?as=dependency"],"files":[{"path":"registry/subscription.tsx","type":"registry:component","content":"// Hairline UI · subscription · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowDownToLine, ChevronLeft } from \"lucide-react\";\nimport { Faces, FACES_MORPH } from \"./faces\";\nimport { NumberRoll } from \"./number-roll\";\nimport { SegmentedControl } from \"./segmented-control\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { Textarea } from \"./textarea\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * SUBSCRIPTION: a plan you can see through to its next bill\n *\n *   plan      the lit card holds the plan, its price and a ring\n *             that fills through the billing period, the days left\n *             in its middle; under it usage, the card on file and\n *             invoices, all flat\n *   pause     Cancel plan turns the lower half into \"Pause\n *             instead?\": pick one, two or three months and the date\n *             billing resumes rolls to match\n *   cancel    only past the offer, a reason and a note, then\n *             Cancel subscription; the card says when it ends\n *   resume    a paused or ending plan offers Resume in place\n *\n * Billing is yours: pass the plan, status, period and usage from\n * your provider, and do the work in onPause, onCancel, onResume and\n * onUpdateCard (open your provider's portal there). Throw an Error\n * and its message shows where it happened.\n * ───────────────────────────────────────────────────────── */\n\nexport type SubscriptionStatus = \"active\" | \"trialing\" | \"past_due\" | \"paused\" | \"canceling\";\n\nexport interface SubscriptionPlan {\n  name: string;\n  /** Per interval, major units. */\n  price: number;\n  interval: \"month\" | \"year\";\n  seats?: number;\n}\n\nexport interface SubscriptionUsage {\n  id: string;\n  label: string;\n  used: number;\n  limit: number;\n  /** e.g. \"runs\", \"GB\". */\n  unit?: string;\n}\n\nexport interface SubscriptionCard {\n  /** e.g. \"Visa\". */\n  brand: string;\n  last4: string;\n  expMonth: number;\n  expYear: number;\n}\n\nexport interface SubscriptionInvoice {\n  id: string;\n  date: Date | string | number;\n  amount: number;\n  status: \"paid\" | \"open\" | \"failed\";\n  /** A PDF or hosted invoice page. */\n  url?: string;\n}\n\nexport interface SubscriptionProps {\n  plan: SubscriptionPlan;\n  status: SubscriptionStatus;\n  periodStart: Date | string | number;\n  /** When it renews, or ends while canceling. */\n  periodEnd: Date | string | number;\n  /** When billing resumes, while paused. */\n  resumesAt?: Date | string | number;\n  usage?: SubscriptionUsage[];\n  card?: SubscriptionCard;\n  invoices?: SubscriptionInvoice[];\n  /** Open your provider's portal (or a form) to change the card. */\n  onUpdateCard?: () => Promise<unknown>;\n  /** Go to your plans, e.g. the Pricing plans block. */\n  onChangePlan?: () => void;\n  /** Pause billing for this many months; shows the offer before cancelling. */\n  onPause?: (months: number) => Promise<unknown>;\n  onCancel?: (reason: string, comment: string) => Promise<unknown>;\n  /** Undo a pause or a cancel that hasn't ended yet. */\n  onResume?: () => Promise<unknown>;\n  pauseMonths?: number[];\n  reasons?: string[];\n  currency?: string;\n  locale?: string;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DAY = 86_400_000;\nconst PAUSE = [1, 2, 3];\nconst REASONS = [\"Too expensive\", \"Not using it enough\", \"Missing a feature\", \"Switching to another tool\", \"Something else\"];\nconst NONE: never[] = [];\nconst subscribeNothing = () => () => {};\n/** False on the server and while hydrating, so dates are written once, in the visitor's own zone. */\nconst useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst LIT: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 90% 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 ACTION: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, ${mix(\"var(--primary-foreground)\", 16)}, transparent 60%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 32)}, inset 0 0 0 1px ${mix(\"black\", 10)}`,\n};\nconst errorOf = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\nconst addMonths = (t: number, m: number) => {\n  const d = new Date(t);\n  d.setMonth(d.getMonth() + m);\n  return d.getTime();\n};\n\n/** The billing period as a ring: how far through it you are, the days left in the middle. */\nfunction PeriodRing({ share, days, dim, reduced }: { share: number; days: number | null; dim: boolean; reduced: boolean }) {\n  const r = 22;\n  const c = 2 * Math.PI * r;\n  return (\n    <span className=\"relative flex size-[56px] shrink-0 items-center justify-center\" style={{ opacity: dim ? 0.55 : 1, transition: \"opacity 400ms\" }}>\n      <svg aria-hidden=\"true\" viewBox=\"0 0 56 56\" className=\"absolute inset-0 size-full -rotate-90\">\n        <circle cx=\"28\" cy=\"28\" r={r} fill=\"none\" stroke={mix(\"var(--primary-foreground)\", 22)} strokeWidth=\"3\" />\n        <circle cx=\"28\" cy=\"28\" r={r} fill=\"none\" stroke=\"var(--primary-foreground)\" strokeWidth=\"3\" strokeLinecap=\"round\" strokeDasharray={c} style={{ strokeDashoffset: c * (1 - share), transition: reduced ? \"none\" : `stroke-dashoffset 900ms ${EASE}` }} />\n        {/* Fine ticks round the ring, one per week of the period. */}\n        {Array.from({ length: 12 }, (_, i) => (\n          <line key={i} x1=\"28\" y1=\"1.5\" x2=\"28\" y2=\"3.5\" stroke={mix(\"var(--primary-foreground)\", 35)} strokeWidth=\"0.8\" transform={`rotate(${i * 30} 28 28)`} />\n        ))}\n      </svg>\n      <span className=\"relative text-center leading-none\">\n        <span className=\"block text-[16px] font-medium tabular-nums\">{days === null ? \"–\" : <NumberRoll value={days} duration={800} />}</span>\n        <span className=\"mt-0.5 block text-[9.5px] uppercase tracking-[0.08em] text-primary-foreground/70\">{days === 1 ? \"day\" : \"days\"}</span>\n      </span>\n    </span>\n  );\n}\n\nexport function Subscription({\n  plan,\n  status,\n  periodStart,\n  periodEnd,\n  resumesAt,\n  usage = NONE,\n  card,\n  invoices = NONE,\n  onUpdateCard,\n  onChangePlan,\n  onPause,\n  onCancel,\n  onResume,\n  pauseMonths = PAUSE,\n  reasons = REASONS,\n  currency = \"USD\",\n  locale,\n  className = \"\",\n}: SubscriptionProps) {\n  const reduced = useInteractionReducedMotion();\n  const hydrated = useHydrated();\n  const baseId = React.useId();\n  const loc = locale ?? (hydrated ? undefined : \"en-US\");\n  const [now, setNow] = React.useState<number | null>(null);\n  const [ready, setReady] = React.useState(false);\n  const [step, setStep] = React.useState<0 | 1 | 2>(0);\n  const [months, setMonths] = React.useState(pauseMonths[Math.min(1, pauseMonths.length - 1)] ?? 1);\n  const [reason, setReason] = React.useState<string | null>(null);\n  const [comment, setComment] = React.useState(\"\");\n  const [act, setAct] = React.useState<{ kind: \"pause\" | \"cancel\" | \"resume\" | \"card\" | null; status: ActionStatus; error?: string }>({ kind: null, status: \"idle\" });\n  const [said, setSaid] = React.useState(\"\");\n  const faceRefs = React.useRef<(HTMLElement | null)[]>([]);\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => {\n      setNow(Date.now());\n      setReady(true);\n    });\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const start = new Date(periodStart).getTime();\n  const end = new Date(periodEnd).getTime();\n  const share = now === null ? 0 : Math.min(1, Math.max(0, (now - start) / (end - start)));\n  // While paused the ring counts down to when billing comes back, not to the old renewal.\n  const until = status === \"paused\" && resumesAt !== undefined ? new Date(resumesAt).getTime() : end;\n  const daysLeft = now === null ? null : Math.max(0, Math.ceil((until - now) / DAY));\n  const date = (t: Date | string | number) => (hydrated ? new Intl.DateTimeFormat(loc, { month: \"short\", day: \"numeric\", year: new Date(t).getFullYear() !== new Date(now ?? end).getFullYear() ? \"numeric\" : undefined }).format(new Date(t)) : \"\");\n  const money = (n: number) => new Intl.NumberFormat(loc, { style: \"currency\", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 }).format(n);\n  const resumeAt = addMonths(end, months);\n  const busy = act.status === \"pending\" || act.status === \"success\";\n\n  const state =\n    status === \"paused\"\n      ? `Paused until ${date(resumesAt ?? end)}`\n      : status === \"canceling\"\n        ? `Ends ${date(end)}`\n        : status === \"trialing\"\n          ? `Trial ends ${date(end)}`\n          : status === \"past_due\"\n            ? \"Payment failed\"\n            : `Renews ${date(end)}`;\n\n  const go = (s: 0 | 1 | 2) => {\n    if (busy) return;\n    setStep(s);\n    setAct({ kind: null, status: \"idle\" });\n    // Focus follows once the surface is mostly turned.\n    window.setTimeout(() => faceRefs.current[s]?.focus({ preventScroll: true }), reduced ? 0 : FACES_MORPH / 2);\n  };\n\n  const run = async (kind: \"pause\" | \"cancel\" | \"resume\" | \"card\", fn: (() => Promise<unknown>) | undefined, done: string) => {\n    if (!fn || busy) return;\n    setAct({ kind, status: \"pending\" });\n    try {\n      await fn();\n      setAct({ kind, status: \"success\" });\n      setSaid(done);\n      window.setTimeout(\n        () => {\n          if (kind !== \"card\") setStep(0);\n          setAct({ kind: null, status: \"idle\" });\n        },\n        kind === \"card\" ? 1200 : reduced ? 600 : 900,\n      );\n    } catch (e) {\n      setAct({ kind, status: \"error\", error: errorOf(e, \"That didn’t go through. Try again.\") });\n    }\n  };\n\n  const st = (kind: \"pause\" | \"cancel\" | \"resume\" | \"card\") => (act.kind === kind ? act.status : \"idle\");\n  const ghost = `inline-flex h-10 cursor-pointer items-center justify-center rounded-full px-4 text-[13.5px] font-medium ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n  const ending = status === \"paused\" || status === \"canceling\";\n\n  return (\n    <section aria-labelledby={`${baseId}-plan`} 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        {/* The one lit surface: the plan, and the ring of its billing period. */}\n        <div className=\"rounded-[22px] p-4 text-primary-foreground\" style={{ ...LIT, filter: ending ? \"saturate(0.55)\" : \"none\", transition: reduced ? \"none\" : `filter 500ms ${EASE}` }}>\n          <div className=\"flex items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              <p className=\"text-[12.5px] text-primary-foreground/70\">Your plan</p>\n              <h2 id={`${baseId}-plan`} className=\"mt-0.5 text-[20px] font-medium leading-tight tracking-[-0.02em]\">\n                {plan.name}\n                {plan.seats ? <span className=\"text-primary-foreground/70\"> · {plan.seats} seats</span> : null}\n              </h2>\n              <p className=\"mt-2 flex items-baseline gap-1\">\n                <span className=\"text-[30px] font-medium leading-none tracking-[-0.04em] tabular-nums\">\n                  <NumberRoll value={plan.price} locales={locale} format={{ style: \"currency\", currency, minimumFractionDigits: plan.price % 1 ? 2 : 0, maximumFractionDigits: 2 }} duration={800} />\n                </span>\n                <span className=\"text-[13px] text-primary-foreground/70\">/{plan.interval === \"year\" ? \"yr\" : \"mo\"}</span>\n              </p>\n            </div>\n            <PeriodRing share={ending ? 1 : share} days={daysLeft} dim={ending} reduced={reduced} />\n          </div>\n          <p className={`mt-3 inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12.5px] font-medium ${status === \"past_due\" ? \"text-amber-200\" : \"\"}`} style={{ background: mix(\"var(--primary-foreground)\", 14) }} role=\"status\">\n            <span aria-hidden=\"true\" className={`size-1.5 rounded-full ${status === \"past_due\" ? \"bg-amber-400\" : ending ? \"bg-primary-foreground/55\" : \"bg-emerald-300\"}`} />\n            <TextMorph>{hydrated ? state : \" \"}</TextMorph>\n          </p>\n        </div>\n\n        {/* Flat: the details, or the way out, which always offers a pause first. */}\n        <Faces active={step} reduced={reduced} ready={ready}>\n          <div\n            ref={(el) => {\n              faceRefs.current[0] = el;\n            }}\n            tabIndex={-1}\n            className=\"px-2 pb-1 pt-1 outline-none\"\n          >\n            {usage.length > 0 && (\n              <div className=\"space-y-2.5 px-1 pt-3\">\n                {usage.map((u) => {\n                  const pct = Math.min(1, u.used / u.limit);\n                  const hot = pct >= 0.9;\n                  return (\n                    <div key={u.id}>\n                      <div className=\"flex items-baseline justify-between gap-3 text-[13px]\">\n                        <span className=\"text-foreground\">{u.label}</span>\n                        <span className=\"tabular-nums text-muted-foreground\">\n                          <NumberRoll value={u.used} locales={locale} className={hot ? \"font-medium text-amber-600 dark:text-amber-400\" : \"text-foreground\"} /> / {new Intl.NumberFormat(loc).format(u.limit)}\n                          {u.unit ? ` ${u.unit}` : \"\"}\n                        </span>\n                      </div>\n                      <div className=\"mt-1.5 h-1 overflow-hidden rounded-full bg-muted\" role=\"meter\" aria-label={u.label} aria-valuemin={0} aria-valuemax={u.limit} aria-valuenow={u.used}>\n                        <span className={`block h-full origin-left rounded-full ${hot ? \"bg-amber-500\" : \"bg-primary\"}`} style={{ transform: `scaleX(${ready ? pct : 0})`, transition: reduced ? \"none\" : `transform 900ms ${EASE}` }} />\n                      </div>\n                    </div>\n                  );\n                })}\n              </div>\n            )}\n\n            {card && (\n              <div className=\"mt-3 flex h-12 items-center gap-3 border-t border-border px-1 pt-3\">\n                <span aria-hidden=\"true\" className=\"flex h-7 w-10 shrink-0 items-center justify-center rounded-[6px] border border-border bg-background font-mono text-[9px] font-medium uppercase tracking-[0.06em] text-foreground\">\n                  {card.brand.slice(0, 4)}\n                </span>\n                <span className=\"min-w-0 flex-1 text-[13px]\">\n                  <span className=\"block truncate text-foreground\">\n                    {card.brand} •••• {card.last4}\n                  </span>\n                  <span className=\"block text-[12px] text-muted-foreground\">\n                    Expires {String(card.expMonth).padStart(2, \"0\")}/{String(card.expYear).slice(-2)}\n                  </span>\n                </span>\n                {onUpdateCard && (\n                  <StatusButton\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    status={st(\"card\")}\n                    labels={{ idle: \"Update\", pending: \"Opening\", success: \"Opened\", error: \"Try again\" }}\n                    onClick={() => void run(\"card\", onUpdateCard, \"Opened the payment page.\")}\n                    className=\"-mr-1 shrink-0 text-foreground\"\n                  />\n                )}\n              </div>\n            )}\n            {act.kind === \"card\" && act.error && (\n              <p role=\"alert\" className=\"px-1 pt-1 text-[12px] text-red-600 dark:text-red-400\">\n                {act.error}\n              </p>\n            )}\n\n            {invoices.length > 0 && (\n              <div className=\"mt-3 border-t border-border px-1 pt-2.5\">\n                <p className=\"text-[12px] text-muted-foreground\">Invoices</p>\n                <ul className=\"mt-1\">\n                  {invoices.slice(0, 3).map((inv) => (\n                    <li key={inv.id} className=\"flex h-9 items-center gap-3 text-[13px]\">\n                      <span className=\"min-w-0 flex-1 text-foreground\">{date(inv.date)}</span>\n                      <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${inv.status === \"paid\" ? \"bg-emerald-500/10 text-emerald-700 dark:text-emerald-400\" : inv.status === \"open\" ? \"bg-amber-500/10 text-amber-700 dark:text-amber-400\" : \"bg-red-500/10 text-red-600 dark:text-red-400\"}`}>\n                        {inv.status === \"paid\" ? \"Paid\" : inv.status === \"open\" ? \"Open\" : \"Failed\"}\n                      </span>\n                      <span className=\"w-[72px] text-right tabular-nums text-foreground\">{money(inv.amount)}</span>\n                      {inv.url ? (\n                        <a href={inv.url} target=\"_blank\" rel=\"noreferrer\" aria-label={`Download the invoice of ${date(inv.date)}`} className={`-mr-1 flex size-7 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_FOCUS}`}>\n                          <ArrowDownToLine aria-hidden=\"true\" className=\"size-3.5\" />\n                        </a>\n                      ) : (\n                        <span className=\"w-6\" />\n                      )}\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            )}\n\n            <div className=\"mt-2.5 flex gap-2 border-t border-border pt-3\">\n              {ending && onResume ? (\n                <StatusButton\n                  status={st(\"resume\")}\n                  labels={{ idle: status === \"paused\" ? \"Resume now\" : \"Keep my plan\", pending: \"Resuming\", success: \"Resumed\", error: \"Try again\" }}\n                  onClick={() => void run(\"resume\", onResume, \"Your plan is back on.\")}\n                  className=\"h-10 flex-1 text-[13.5px]\"\n                  style={st(\"resume\") === \"error\" ? undefined : ACTION}\n                />\n              ) : (\n                onChangePlan && (\n                  <button type=\"button\" onClick={onChangePlan} className={`${ghost} flex-1 text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent`}>\n                    Change plan\n                  </button>\n                )\n              )}\n              {!ending && (onPause || onCancel) && (\n                <button type=\"button\" onClick={() => go(onPause ? 1 : 2)} className={`${ghost} text-muted-foreground hover:bg-accent hover:text-foreground`}>\n                  Cancel plan\n                </button>\n              )}\n            </div>\n            {act.kind === \"resume\" && act.error && (\n              <p role=\"alert\" className=\"px-1 pt-1.5 text-[12px] text-red-600 dark:text-red-400\">\n                {act.error}\n              </p>\n            )}\n          </div>\n\n          {/* Before you go: a pause, with the date it all comes back. */}\n          <div\n            ref={(el) => {\n              faceRefs.current[1] = el;\n            }}\n            tabIndex={-1}\n            role=\"group\"\n            aria-labelledby={`${baseId}-pause`}\n            className=\"px-3 pb-1.5 pt-3.5 outline-none\"\n          >\n            <h3 id={`${baseId}-pause`} className=\"text-[16px] font-medium tracking-[-0.01em]\">\n              Pause instead?\n            </h3>\n            <p className=\"mt-1 text-[13px] leading-relaxed text-muted-foreground\">Keep everything as it is and pick up where you left off. You won’t be charged while it’s paused.</p>\n            <SegmentedControl\n              label=\"Pause for\"\n              block\n              className=\"mt-3\"\n              value={String(months)}\n              onValueChange={(v) => setMonths(Number(v))}\n              options={pauseMonths.map((m) => ({ value: String(m), label: m === 1 ? \"1 month\" : `${m} months` }))}\n            />\n            <p className=\"mt-2.5 text-[13px] text-muted-foreground\">\n              Billing resumes on <TextMorph className=\"font-medium text-foreground\">{hydrated ? date(resumeAt) : \" \"}</TextMorph>\n            </p>\n            <StatusButton\n              status={st(\"pause\")}\n              labels={{ idle: months === 1 ? \"Pause for 1 month\" : `Pause for ${months} months`, pending: \"Pausing\", success: \"Paused\", error: \"Try again\" }}\n              onClick={() => void run(\"pause\", onPause && (() => onPause(months)), `Paused for ${months} months.`)}\n              className=\"mt-3.5 h-11 w-full text-[14.5px]\"\n              style={st(\"pause\") === \"error\" ? undefined : ACTION}\n            />\n            {act.kind === \"pause\" && act.error && (\n              <p role=\"alert\" className=\"mt-1.5 text-center text-[12px] text-red-600 dark:text-red-400\">\n                {act.error}\n              </p>\n            )}\n            <div className=\"mt-1.5 flex justify-between\">\n              <button type=\"button\" onClick={() => go(0)} disabled={busy} className={`${ghost} -ml-2 h-9 px-3 text-muted-foreground hover:bg-accent hover:text-foreground`}>\n                Keep my plan\n              </button>\n              {onCancel && (\n                <button type=\"button\" onClick={() => go(2)} disabled={busy} className={`${ghost} -mr-2 h-9 px-3 text-muted-foreground hover:bg-accent hover:text-foreground`}>\n                  No, cancel it\n                </button>\n              )}\n            </div>\n          </div>\n\n          {/* Cancel: a reason, then the end date. */}\n          <div\n            ref={(el) => {\n              faceRefs.current[2] = el;\n            }}\n            tabIndex={-1}\n            role=\"group\"\n            aria-labelledby={`${baseId}-cancel`}\n            className=\"px-3 pb-1.5 pt-3 outline-none\"\n          >\n            <div className=\"-ml-1.5 flex items-center gap-1\">\n              <button type=\"button\" aria-label=\"Back\" disabled={busy} onClick={() => go(onPause ? 1 : 0)} className={`flex size-8 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                <ChevronLeft aria-hidden=\"true\" className=\"size-[18px]\" />\n              </button>\n              <h3 id={`${baseId}-cancel`} className=\"text-[16px] font-medium tracking-[-0.01em]\">\n                Why are you leaving?\n              </h3>\n            </div>\n            <div role=\"radiogroup\" aria-labelledby={`${baseId}-cancel`} className=\"mt-2.5 flex flex-wrap gap-1.5\">\n              {reasons.map((r) => {\n                const on = r === reason;\n                return (\n                  <button\n                    key={r}\n                    type=\"button\"\n                    role=\"radio\"\n                    aria-checked={on}\n                    disabled={busy}\n                    onClick={() => setReason(r)}\n                    className={`h-8 cursor-pointer rounded-full px-3 text-[12.5px] ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${on ? \"bg-primary/[0.08] font-medium text-primary shadow-[inset_0_0_0_1.5px_var(--primary)]\" : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\"}`}\n                    style={{ transition: reduced ? \"none\" : `box-shadow 260ms ${EASE_THROW}, background-color 200ms` }}\n                  >\n                    {r}\n                  </button>\n                );\n              })}\n            </div>\n            <Textarea aria-label=\"Anything we could have done better?\" placeholder=\"Anything we could have done better? (optional)\" value={comment} onValueChange={setComment} disabled={busy} minRows={2} maxRows={4} maxLength={500} showCount={false} className=\"mt-2.5\" />\n            <StatusButton\n              variant=\"outline\"\n              status={st(\"cancel\")}\n              labels={{ idle: \"Cancel subscription\", pending: \"Cancelling\", success: \"Cancelled\", error: \"Try again\" }}\n              disabled={!reason && st(\"cancel\") === \"idle\"}\n              onClick={() => void run(\"cancel\", onCancel && (() => onCancel(reason ?? \"\", comment.trim())), `Cancelled. ${plan.name} stays on until ${date(end)}.`)}\n              className=\"mt-2.5 h-11 w-full text-[14.5px] text-red-600 disabled:opacity-45 dark:text-red-400\"\n            />\n            <p role={act.kind === \"cancel\" && act.error ? \"alert\" : undefined} className={`mt-1.5 text-center text-[12px] ${act.kind === \"cancel\" && act.error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n              {act.kind === \"cancel\" && act.error ? act.error : hydrated ? `You keep ${plan.name} until ${date(end)}.` : \" \"}\n            </p>\n          </div>\n        </Faces>\n\n        <span className=\"sr-only\" role=\"status\">\n          {said}\n        </span>\n      </div>\n    </section>\n  );\n}\n\nexport default Subscription;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · subscription · 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 · subscription · 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 · subscription · 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/subscription. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}