{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pricing-plans","type":"registry:block","title":"Pricing plans","description":"Plans that turn into their own checkout: pick one and its card becomes the order, seats and totals rolling, then Continue opens your checkout.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/pricing-toggle.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/stepper-input.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/pricing-plans.tsx","type":"registry:component","content":"// Hairline UI · pricing-plans · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Check, X } from \"lucide-react\";\nimport { Faces, FACES_MORPH } from \"./faces\";\nimport { NumberRoll } from \"./number-roll\";\nimport { PricingToggle } from \"./pricing-toggle\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { StepperInput } from \"./stepper-input\";\nimport { TextMorph } from \"./text-morph\";\nimport { INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * PRICING PLANS: plans that turn into their own checkout\n *\n *   plans     flat hairline cards; the featured plan is the lit\n *             surface, ruled with fine ledger hairlines. Billing\n *             toggles roll every price the way it moved\n *   choose    the plan you pick turns into its checkout in place,\n *             a slip on the same card: seats on a stepper, the\n *             line items and the total rolling as seats or billing\n *             change, while the other plans step back\n *   checkout  Continue goes Opening checkout → Redirecting through\n *             your onCheckout (create the Stripe or Paddle session\n *             there); a failure keeps the slip and says why\n *   current   with currentPlanId, buttons read Current plan,\n *             Upgrade or Downgrade, for a billing settings page\n * ───────────────────────────────────────────────────────── */\n\nexport type Billing = \"monthly\" | \"yearly\";\n\nexport interface PricingPlan {\n  id: string;\n  name: string;\n  description?: string;\n  /** Per month, in major units; per seat when perSeat. Yearly is the monthly rate when billed yearly. */\n  price: { monthly: number; yearly: number };\n  perSeat?: boolean;\n  minSeats?: number;\n  maxSeats?: number;\n  features: string[];\n  /** The plan on the lit surface. */\n  featured?: boolean;\n  /** A short tag beside the name, e.g. \"Most popular\". */\n  badge?: string;\n  /** Button label; defaults to \"Get <name>\", \"Start free\" or Upgrade / Downgrade. */\n  cta?: string;\n  /** A link instead of checkout, e.g. \"Contact sales\" for an enterprise plan. */\n  href?: string;\n}\n\nexport interface PricingCheckout {\n  plan: PricingPlan;\n  billing: Billing;\n  seats: number;\n  /** What's due now, in major units: a month, or a year when billed yearly. */\n  total: number;\n}\n\nexport interface PricingPlansProps {\n  plans: PricingPlan[];\n  billing?: Billing;\n  defaultBilling?: Billing;\n  onBillingChange?: (billing: Billing) => void;\n  /** The plan the account is on, for a billing settings page. */\n  currentPlanId?: string;\n  /** Start checkout; resolve once you're redirecting (or done), throw an Error to show its message. */\n  onCheckout?: (checkout: PricingCheckout) => Promise<unknown>;\n  currency?: string;\n  locale?: string;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\n\n/* The lit plan: --primary with a lit corner from --primary-foreground, a deeper lower edge and 1px light edges. */\nconst LIT_SURFACE: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 90% at 0% 0%, ${mix(\"var(--primary-foreground)\", 22)} 0%, transparent 55%), linear-gradient(170deg, transparent 45%, ${mix(\"black\", 22)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 36)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\n};\n/* Its signature: fine ledger rules, fading out from the top right. */\nconst LEDGER: React.CSSProperties = {\n  backgroundImage: `repeating-linear-gradient(180deg, transparent 0 11px, ${mix(\"var(--primary-foreground)\", 10)} 11px 12px)`,\n  maskImage: \"radial-gradient(130% 80% at 100% 0%, black 0%, transparent 70%)\",\n  WebkitMaskImage: \"radial-gradient(130% 80% at 100% 0%, black 0%, transparent 70%)\",\n};\nconst LIT_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 COLS: Record<number, string> = { 1: \"\", 2: \"@[640px]:grid-cols-2\", 3: \"@[640px]:grid-cols-3\", 4: \"@[760px]:grid-cols-2 @[1040px]:grid-cols-4\" };\n\nfunction useMoney(locale: string | undefined, currency: string) {\n  return React.useMemo(() => {\n    const whole = new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 });\n    const cents = new Intl.NumberFormat(locale, { style: \"currency\", currency });\n    return { format: (n: number) => (n % 1 ? cents : whole).format(n) };\n  }, [locale, currency]);\n}\n\nconst priceFormat = (currency: string, n: number): Intl.NumberFormatOptions => ({ style: \"currency\", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: n % 1 ? 2 : 0 });\n\nexport function PricingPlans({\n  plans,\n  billing: billingProp,\n  defaultBilling = \"monthly\",\n  onBillingChange,\n  currentPlanId,\n  onCheckout,\n  currency = \"USD\",\n  locale,\n  className = \"\",\n}: PricingPlansProps) {\n  const reduced = useInteractionReducedMotion();\n  const money = useMoney(locale, currency);\n  const baseId = React.useId();\n  const [ownBilling, setOwnBilling] = React.useState<Billing>(defaultBilling);\n  const billing = billingProp ?? ownBilling;\n  const [dir, setDir] = React.useState(1);\n  const [openId, setOpenId] = React.useState<string | null>(null);\n  const [seats, setSeats] = React.useState<Record<string, number>>({});\n  const [status, setStatus] = React.useState<{ id: string; status: ActionStatus; error?: string } | null>(null);\n  const [ready, setReady] = React.useState(false);\n  const slipRefs = React.useRef(new Map<string, HTMLDivElement>());\n  const ctaRefs = React.useRef(new Map<string, HTMLElement>());\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  React.useEffect(() => {\n    if (!ready || !openId) return;\n    slipRefs.current.get(openId)?.focus({ preventScroll: true });\n  }, [openId, ready]);\n\n  const setBilling = (next: Billing) => {\n    if (next === billing) return;\n    setDir(next === \"yearly\" ? 1 : -1);\n    if (billingProp === undefined) setOwnBilling(next);\n    onBillingChange?.(next);\n  };\n\n  // The toggle's badge says what yearly saves, from the plans themselves.\n  const save = Math.max(0, ...plans.filter((p) => p.price.monthly > 0).map((p) => Math.round((1 - p.price.yearly / p.price.monthly) * 100)));\n  const options = React.useMemo(() => [{ value: \"monthly\", label: \"Monthly\" }, { value: \"yearly\", label: \"Yearly\", ...(save > 0 ? { badge: `Save ${save}%` } : {}) }], [save]);\n\n  const current = plans.find((p) => p.id === currentPlanId);\n  const seatsOf = (p: PricingPlan) => (p.perSeat ? Math.max(p.minSeats ?? 1, seats[p.id] ?? p.minSeats ?? 1) : 1);\n  const totalOf = (p: PricingPlan) => p.price[billing] * seatsOf(p) * (billing === \"yearly\" ? 12 : 1);\n  const busy = status?.status === \"pending\" || status?.status === \"success\";\n\n  const close = () => {\n    if (busy || !openId) return;\n    const id = openId;\n    setOpenId(null);\n    setStatus(null);\n    window.setTimeout(() => ctaRefs.current.get(id)?.focus({ preventScroll: true }), reduced ? 0 : FACES_MORPH);\n  };\n\n  const checkout = async (p: PricingPlan) => {\n    if (busy) return;\n    setStatus({ id: p.id, status: \"pending\" });\n    try {\n      await onCheckout?.({ plan: p, billing, seats: seatsOf(p), total: totalOf(p) });\n      setStatus({ id: p.id, status: \"success\" });\n    } catch (e) {\n      setStatus({ id: p.id, status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t start checkout. Try again.\" });\n    }\n  };\n\n  const ctaLabel = (p: PricingPlan) => {\n    if (p.id === currentPlanId) return \"Current plan\";\n    if (current) return p.price.monthly >= current.price.monthly ? `Upgrade to ${p.name}` : `Switch to ${p.name}`;\n    return p.cta ?? (p.price.monthly === 0 ? \"Start free\" : `Get ${p.name}`);\n  };\n\n  return (\n    <div\n      className={`@container w-full [contain-intrinsic-inline-size:960px] ${className}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && openId) {\n          e.preventDefault();\n          close();\n        }\n      }}\n    >\n      <div className=\"flex justify-center\">\n        <PricingToggle value={billing} onValueChange={(v) => setBilling(v as Billing)} options={options} />\n      </div>\n\n      <ul className={`mt-6 grid grid-cols-1 items-stretch gap-3 ${COLS[Math.min(plans.length, 4)] ?? \"\"}`}>\n        {plans.map((p) => {\n          const lit = Boolean(p.featured);\n          const isOpen = openId === p.id;\n          const isCurrent = p.id === currentPlanId;\n          const amount = p.price[billing];\n          const free = p.price.monthly === 0;\n          const st = status?.id === p.id ? status : null;\n          const n = seatsOf(p);\n          const total = totalOf(p);\n          const note = free ? \"Free forever\" : billing === \"yearly\" ? `${money.format(amount * 12)}${p.perSeat ? \" per seat\" : \"\"} billed yearly` : p.perSeat ? \"Per seat, billed monthly\" : \"Billed monthly\";\n          const muted = lit ? \"text-primary-foreground/70\" : \"text-muted-foreground\";\n          const dimmed = openId !== null && !isOpen;\n\n          return (\n            <li\n              key={p.id}\n              className={`relative flex flex-col overflow-clip rounded-[28px] ${isOpen ? \"self-start\" : \"\"} ${lit ? \"text-primary-foreground\" : \"border border-border bg-card text-card-foreground\"}`}\n              style={{\n                ...(lit ? LIT_SURFACE : {}),\n                opacity: dimmed ? 0.5 : 1,\n                transform: dimmed && !reduced ? \"scale(0.985)\" : \"none\",\n                transition: reduced ? \"opacity 160ms\" : `opacity 320ms ${EASE}, transform ${FACES_MORPH}ms ${EASE}`,\n              }}\n            >\n              {lit && <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" style={LEDGER} />}\n              <Faces active={isOpen ? 1 : 0} reduced={reduced} ready={ready} className=\"flex-1\">\n                {/* The plan. */}\n                <div className=\"flex h-full flex-col p-6\">\n                  <div className=\"flex items-center gap-2\">\n                    <h3 id={`${baseId}-${p.id}`} className=\"text-[16px] font-medium tracking-[-0.01em]\">\n                      {p.name}\n                    </h3>\n                    {(p.badge || isCurrent) && (\n                      <span className={`rounded-full px-2 py-0.5 text-[11.5px] font-medium ${lit ? \"bg-primary-foreground/15 text-primary-foreground\" : \"bg-primary/10 text-primary\"}`}>\n                        {isCurrent ? \"Your plan\" : p.badge}\n                      </span>\n                    )}\n                  </div>\n                  {p.description && <p className={`mt-1.5 text-[13.5px] leading-relaxed ${muted}`}>{p.description}</p>}\n\n                  <div className=\"mt-6 flex items-baseline gap-1.5\">\n                    <span className=\"text-[clamp(40px,7cqw,52px)] font-medium leading-none tracking-[-0.045em] tabular-nums\">\n                      {free ? \"Free\" : <NumberRoll value={amount} locales={locale} format={priceFormat(currency, amount)} />}\n                    </span>\n                    {!free && <span className={`text-[13.5px] ${muted}`}>{p.perSeat ? \"/seat/mo\" : \"/mo\"}</span>}\n                  </div>\n                  <div className={`relative mt-2 h-5 overflow-hidden text-[12.5px] ${muted}`}>\n                    <span key={note} className=\"absolute inset-0 truncate motion-safe:animate-[ui-note-in_380ms_cubic-bezier(0.23,1,0.32,1)_both]\" style={{ \"--ui-from\": `${dir > 0 ? 100 : -100}%` } as React.CSSProperties}>\n                      {note}\n                    </span>\n                  </div>\n\n                  <div className=\"mt-6\">\n                    {p.href && !isCurrent ? (\n                      <a\n                        ref={(el) => {\n                          if (el) ctaRefs.current.set(p.id, el);\n                        }}\n                        href={p.href}\n                        className={`flex h-12 w-full items-center justify-center rounded-full text-[15px] font-medium ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${lit ? \"bg-primary-foreground text-primary\" : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\"}`}\n                      >\n                        {p.cta ?? \"Contact sales\"}\n                      </a>\n                    ) : free && !isCurrent ? (\n                      <StatusButton\n                        status={st?.status ?? \"idle\"}\n                        labels={{ idle: ctaLabel(p), pending: \"Setting up\", success: \"You’re in\", error: \"Try again\" }}\n                        variant=\"outline\"\n                        onClick={() => void checkout(p)}\n                        className=\"h-12 w-full text-[15px]\"\n                      />\n                    ) : (\n                      <button\n                        ref={(el) => {\n                          if (el) ctaRefs.current.set(p.id, el);\n                        }}\n                        type=\"button\"\n                        disabled={isCurrent}\n                        aria-expanded={isOpen}\n                        onClick={() => {\n                          setStatus(null);\n                          setOpenId(p.id);\n                        }}\n                        className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-full text-[15px] font-medium disabled:cursor-default ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${\n                          isCurrent\n                            ? lit\n                              ? \"bg-primary-foreground/15 text-primary-foreground/80\"\n                              : \"bg-muted text-muted-foreground\"\n                            : lit\n                              ? \"bg-primary-foreground text-primary hover:bg-primary-foreground/90\"\n                              : \"bg-primary text-primary-foreground hover:bg-primary/90\"\n                        }`}\n                        style={!lit && !isCurrent ? LIT_ACTION : undefined}\n                      >\n                        {ctaLabel(p)}\n                      </button>\n                    )}\n                  </div>\n                  {st?.status === \"error\" && free && <p role=\"alert\" className=\"mt-2 text-center text-[12.5px] text-red-600 dark:text-red-400\">{st.error}</p>}\n\n                  <ul className={`mt-6 space-y-2.5 border-t pt-5 text-[13.5px] ${lit ? \"border-primary-foreground/15\" : \"border-border\"}`}>\n                    {p.features.map((f) => (\n                      <li key={f} className=\"flex gap-2.5\">\n                        <span className={`mt-px flex size-[18px] shrink-0 items-center justify-center rounded-full ${lit ? \"bg-primary-foreground/15\" : \"bg-primary/10 text-primary\"}`}>\n                          <Check aria-hidden=\"true\" className=\"size-3\" strokeWidth={2.5} />\n                        </span>\n                        <span className={lit ? \"text-primary-foreground/90\" : \"text-foreground\"}>{f}</span>\n                      </li>\n                    ))}\n                  </ul>\n                </div>\n\n                {/* Its checkout: a slip on the same card. */}\n                <div className=\"p-2.5\">\n                  <div\n                    ref={(el) => {\n                      if (el) slipRefs.current.set(p.id, el);\n                      else slipRefs.current.delete(p.id);\n                    }}\n                    tabIndex={-1}\n                    role=\"group\"\n                    aria-label={`Checkout for ${p.name}`}\n                    className=\"rounded-[20px] border border-border bg-card p-4 text-card-foreground outline-none\"\n                  >\n                    <div className=\"flex items-center justify-between gap-3\">\n                      <p className=\"text-[15px] font-medium tracking-[-0.01em]\">\n                        {p.name} · <TextMorph>{billing === \"yearly\" ? \"Yearly\" : \"Monthly\"}</TextMorph>\n                      </p>\n                      <button type=\"button\" aria-label=\"Back to plans\" onClick={close} className={`-mr-1.5 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                        <X aria-hidden=\"true\" className=\"size-4\" />\n                      </button>\n                    </div>\n\n                    {p.perSeat && (\n                      <div className=\"mt-4 flex items-center justify-between gap-3\">\n                        <span className=\"text-[13.5px] text-muted-foreground\">Seats</span>\n                        <StepperInput\n                          label=\"Seats\"\n                          value={n}\n                          onValueChange={(v) => !busy && setSeats((s) => ({ ...s, [p.id]: v }))}\n                          min={p.minSeats ?? 1}\n                          max={p.maxSeats ?? 500}\n                          size=\"sm\"\n                          disabled={busy}\n                        />\n                      </div>\n                    )}\n\n                    <dl className=\"mt-4 space-y-2 border-t border-border pt-4 text-[13.5px]\">\n                      <div className=\"flex justify-between gap-3\">\n                        <dt className=\"text-muted-foreground\">\n                          {money.format(amount)}\n                          {p.perSeat ? \" × \" : \"\"}\n                          {p.perSeat && <NumberRoll value={n} duration={500} />}\n                          {p.perSeat ? (n === 1 ? \" seat\" : \" seats\") : \"\"}\n                          {billing === \"yearly\" ? \" × 12 months\" : \" a month\"}\n                        </dt>\n                        <dd className=\"tabular-nums\">\n                          <NumberRoll value={total} locales={locale} format={priceFormat(currency, total)} duration={700} />\n                        </dd>\n                      </div>\n                      {billing === \"monthly\" && p.price.yearly < p.price.monthly ? (\n                        <div className=\"flex justify-between gap-3 text-[12.5px]\">\n                          <dt className=\"text-muted-foreground\">Yearly saves {money.format((p.price.monthly - p.price.yearly) * 12 * n)}</dt>\n                          <dd>\n                            <button type=\"button\" disabled={busy} onClick={() => setBilling(\"yearly\")} className={`cursor-pointer rounded-full text-primary underline-offset-4 hover:underline disabled:cursor-default ${INTERACTION_FOCUS}`}>\n                              Switch\n                            </button>\n                          </dd>\n                        </div>\n                      ) : billing === \"yearly\" ? (\n                        <div className=\"flex justify-between gap-3 text-[12.5px]\">\n                          <dt className=\"text-muted-foreground\">You save</dt>\n                          <dd className=\"tabular-nums text-emerald-600 dark:text-emerald-400\">\n                            <NumberRoll value={(p.price.monthly - p.price.yearly) * 12 * n} locales={locale} format={priceFormat(currency, (p.price.monthly - p.price.yearly) * 12 * n)} duration={700} />\n                          </dd>\n                        </div>\n                      ) : null}\n                    </dl>\n\n                    <div className=\"mt-4 flex items-end justify-between gap-3 border-t border-border pt-4\">\n                      <span className=\"text-[13.5px] text-muted-foreground\">Due today</span>\n                      <span className=\"text-[32px] font-medium leading-none tracking-[-0.04em] tabular-nums\">\n                        <NumberRoll value={total} locales={locale} format={priceFormat(currency, total)} duration={900} />\n                      </span>\n                    </div>\n\n                    <StatusButton\n                      status={st?.status ?? \"idle\"}\n                      labels={{ idle: \"Continue to checkout\", pending: \"Opening checkout\", success: \"Redirecting\", error: \"Try again\" }}\n                      onClick={() => void checkout(p)}\n                      className=\"mt-4 h-12 w-full text-[15px]\"\n                      style={st?.status === \"error\" ? undefined : LIT_ACTION}\n                    />\n                    <p role={st?.status === \"error\" ? \"alert\" : undefined} className={`mt-2 min-h-[18px] text-center text-[12.5px] ${st?.status === \"error\" ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n                      {st?.status === \"error\" ? st.error : \"Cancel any time.\"}\n                    </p>\n                  </div>\n                </div>\n              </Faces>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n\nexport default PricingPlans;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · pricing-plans · 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/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · pricing-plans · 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-note-in":{"from":{"opacity":"0","transform":"translateY(var(--ui-from))"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/pricing-plans. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}