{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pricing-table",
  "type": "registry:ui",
  "title": "Pricing Table",
  "description": "Plans side by side whose prices roll across the columns when the period changes.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/pricing-table.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, Loader, Minus } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\nexport type BillingPeriod = \"monthly\" | \"yearly\";\n\nexport type PricingFeature =\n  | string\n  | {\n      label: React.ReactNode;\n      /** One or two sentences explaining the feature, opened from the label on hover, focus or tap. */\n      hint?: React.ReactNode;\n      /** false draws it struck from the plan. Defaults to true. */\n      included?: boolean;\n    };\n\nexport type PricingPlan = {\n  id: string;\n  name: string;\n  description?: React.ReactNode;\n  /** Monthly price, and the price of a whole year up front. null for plans priced by sales. */\n  price: { monthly: number; yearly: number } | null;\n  /** Shown instead of a number when price is null. */\n  priceLabel?: React.ReactNode;\n  /** Beside the price, e.g. \"per seat / month\". */\n  unit?: React.ReactNode;\n  /** Replaces the billing line under the price, e.g. \"Free forever\". */\n  billingNote?: React.ReactNode;\n  cta: string;\n  /** A heading over the features, like \"Everything in Pro, plus\". */\n  featuresIntro?: React.ReactNode;\n  features: PricingFeature[];\n  recommended?: boolean;\n  /** Pill beside the name. Defaults to \"Recommended\" on the recommended plan. */\n  badge?: React.ReactNode;\n};\n\nexport type PricingTableProps = Omit<React.ComponentProps<\"ul\">, \"children\"> & {\n  plans: PricingPlan[];\n  period?: BillingPeriod;\n  currency?: string;\n  locale?: Intl.LocalesArgument;\n  /** The plan the viewer is on. Its button reads \"Current plan\" and is disabled. */\n  currentPlan?: string;\n  /** Return a promise and the button shows it is working until it settles; a rejection is shown under the button. */\n  onSelectPlan?: (planId: string, period: BillingPeriod) => void | Promise<unknown>;\n  /** Shown under the button when onSelectPlan rejects. */\n  errorMessage?: React.ReactNode;\n};\n\n// NumberFlow runs on the Web Animations API, which wants the curve as a string.\nconst roll = { duration: 620, easing: `cubic-bezier(${ease.out.join(\",\")})` };\n\nexport function PricingTable({\n  plans,\n  period = \"monthly\",\n  currency = \"USD\",\n  locale,\n  currentPlan,\n  onSelectPlan,\n  errorMessage = \"Couldn’t start checkout. Try again.\",\n  className,\n  style,\n  ...rest\n}: PricingTableProps) {\n  const [pending, setPending] = useState<string | null>(null);\n  const [failed, setFailed] = useState<string | null>(null);\n\n  async function select(id: string) {\n    if (pending) return;\n    setFailed(null);\n    const result = onSelectPlan?.(id, period);\n    if (!(result instanceof Promise)) return;\n    setPending(id);\n    try {\n      await result;\n    } catch {\n      setFailed(id);\n    } finally {\n      setPending(null);\n    }\n  }\n\n  return (\n    // Columns come from the container, not the viewport, so the table works in a sidebar or a modal.\n    <div className=\"@container w-full\">\n      <ul\n        data-period={period}\n        style={{ \"--plans\": plans.length, ...style } as React.CSSProperties}\n        className={cn(\"grid grid-cols-1 gap-3 @2xl:grid-cols-[repeat(var(--plans),minmax(0,1fr))] @2xl:gap-x-3 @2xl:gap-y-0\", className)}\n        {...rest}\n      >\n        {plans.map((plan, i) => (\n          <PlanColumn\n            key={plan.id}\n            plan={plan}\n            index={i}\n            period={period}\n            currency={currency}\n            locale={locale}\n            current={plan.id === currentPlan}\n            pending={pending === plan.id}\n            locked={pending != null && pending !== plan.id}\n            failed={failed === plan.id}\n            errorMessage={errorMessage}\n            onSelect={() => select(plan.id)}\n          />\n        ))}\n      </ul>\n    </div>\n  );\n}\n\nfunction PlanColumn({\n  plan,\n  index,\n  period,\n  currency,\n  locale,\n  current,\n  pending,\n  locked,\n  failed,\n  errorMessage,\n  onSelect,\n}: {\n  plan: PricingPlan;\n  index: number;\n  period: BillingPeriod;\n  currency: string;\n  locale?: Intl.LocalesArgument;\n  current: boolean;\n  pending: boolean;\n  locked: boolean;\n  failed: boolean;\n  errorMessage: React.ReactNode;\n  onSelect: () => void;\n}) {\n  const reduce = useReducedMotion();\n  const titleId = useId();\n  const errorId = useId();\n  const yearly = period === \"yearly\";\n  const { price } = plan;\n  const perMonth = price ? (yearly ? price.yearly / 12 : price.monthly) : 0;\n  const free = price != null && price.monthly === 0 && price.yearly === 0;\n  const saves = price != null && !free && price.yearly < price.monthly * 12;\n  // A fixed default locale, so the server and the browser print the same price.\n  const loc = locale ?? \"en-US\";\n  const money = (n: number) =>\n    new Intl.NumberFormat(loc, { style: \"currency\", currency, maximumFractionDigits: n % 1 ? 2 : 0 }).format(n);\n  const badge = plan.badge ?? (plan.recommended ? \"Recommended\" : null);\n\n  // The billing line changes with the period; keyed so it can swap in place.\n  const note: { key: string; node: React.ReactNode } = plan.billingNote\n    ? { key: \"note\", node: plan.billingNote }\n    : !price\n      ? { key: \"custom\", node: \"Billed annually, by invoice\" }\n      : free\n        ? { key: \"free\", node: \"Free forever\" }\n        : yearly\n          ? {\n              key: \"yearly\",\n              node: (\n                <>\n                  {saves && <Struck reduce={!!reduce}>{money(price.monthly * 12)}</Struck>}\n                  <span className=\"tabular\">{money(price.yearly)}</span> billed yearly\n                </>\n              ),\n            }\n          : { key: \"monthly\", node: \"Billed monthly\" };\n\n  return (\n    <li\n      aria-labelledby={titleId}\n      data-recommended={plan.recommended || undefined}\n      data-current={current || undefined}\n      className={cn(\n        \"relative flex min-w-0 flex-col rounded-xl border p-4\",\n        \"@2xl:row-span-4 @2xl:grid @2xl:grid-rows-subgrid @2xl:gap-0\",\n        plan.recommended ? \"border-line-2 bg-raised shadow-[var(--shadow)]\" : \"border-line bg-frame\",\n      )}\n    >\n      {/* Name, badge, description */}\n      <div className=\"flex min-w-0 flex-col gap-1 pb-4\">\n        <div className=\"flex min-h-5 items-center justify-between gap-2\">\n          <h3 id={titleId} className=\"truncate text-[14px] font-medium tracking-[-0.015em] text-fg\">\n            {plan.name}\n          </h3>\n          {badge != null && (\n            <span\n              className={cn(\n                \"shrink-0 rounded-full px-2 text-[10.5px] font-medium leading-[18px]\",\n                plan.recommended ? \"bg-fg text-frame\" : \"border border-line-2 text-fg-2\",\n              )}\n            >\n              {badge}\n            </span>\n          )}\n        </div>\n        {plan.description != null && <p className=\"text-[12.5px] leading-[18px] text-fg-3 text-pretty\">{plan.description}</p>}\n      </div>\n\n      {/* Price and the billing line under it */}\n      <div className=\"flex flex-col gap-1.5 pb-4\">\n        <div className=\"flex min-h-9 flex-wrap items-baseline gap-x-1.5\">\n          {price ? (\n            <NumberFlow\n              value={perMonth}\n              locales={loc}\n              format={{ style: \"currency\", currency, maximumFractionDigits: perMonth % 1 ? 2 : 0 }}\n              // Columns roll one after another, left to right, so the eye can follow the change across.\n              transformTiming={{ ...roll, delay: index * 45 }}\n              spinTiming={{ ...roll, delay: index * 45 }}\n              className=\"text-[30px] font-medium leading-9 tracking-[-0.035em] text-fg\"\n            />\n          ) : (\n            <span className=\"text-[30px] font-medium leading-9 tracking-[-0.035em] text-fg\">{plan.priceLabel ?? \"Custom\"}</span>\n          )}\n          {price && !free && plan.unit != null && <span className=\"text-[12px] text-fg-3\">{plan.unit}</span>}\n        </div>\n        <p className=\"relative h-[18px] overflow-hidden text-[12px] leading-[18px] text-fg-3\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={note.key}\n              className=\"absolute inset-x-0 top-0 truncate whitespace-nowrap\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n            >\n              {note.node}\n            </motion.span>\n          </AnimatePresence>\n        </p>\n      </div>\n\n      {/* The action */}\n      <div className=\"flex flex-col pb-5\">\n        <button\n          type=\"button\"\n          onClick={onSelect}\n          disabled={current || locked}\n          aria-busy={pending || undefined}\n          aria-describedby={failed ? errorId : undefined}\n          data-variant={plan.recommended ? \"primary\" : \"secondary\"}\n          className={cn(\n            \"relative inline-flex h-9 w-full select-none items-center justify-center rounded-lg px-3 text-[13px] font-medium tracking-[-0.005em]\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"transition-[background-color,border-color,color,scale,opacity] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n            plan.recommended\n              ? \"bg-fg text-frame hover:bg-fg/90\"\n              : \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n            current && \"border-line bg-transparent text-fg-3 shadow-none hover:border-line hover:bg-transparent\",\n            locked && !current && \"opacity-50\",\n            \"disabled:pointer-events-none\",\n            pending && \"pointer-events-none\",\n          )}\n        >\n          <span className={cn(\"flex items-center gap-1.5 transition-opacity duration-150\", pending && \"opacity-0\")}>\n            {current && <Check size={14} />}\n            {current ? \"Current plan\" : plan.cta}\n          </span>\n          {pending && (\n            <span className=\"absolute inset-0 grid place-items-center\" aria-hidden>\n              <Loader size={16} className=\"animate-spin motion-reduce:animate-none\" />\n            </span>\n          )}\n        </button>\n        <AnimatePresence initial={false}>\n          {failed && (\n            // Height opens with the message so the features below slide down instead of jumping.\n            <motion.div\n              className=\"overflow-hidden\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.16, ease: ease.inOut } }}\n              transition={{ duration: reduce ? 0 : 0.22, ease: ease.inOut, opacity: { duration: 0.18 } }}\n            >\n              <p id={errorId} role=\"alert\" className=\"pt-2 text-center text-[12px] leading-4 text-danger text-balance\">\n                {errorMessage}\n              </p>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* What is in it */}\n      <div className=\"flex min-w-0 flex-col gap-2.5 border-t border-line pt-4\">\n        {plan.featuresIntro != null && <p className=\"text-[12px] font-medium text-fg-2\">{plan.featuresIntro}</p>}\n        <ul className=\"flex flex-col gap-2\" aria-label={`${plan.name} features`}>\n          {plan.features.map((f, i) => (\n            <FeatureRow key={i} feature={f} />\n          ))}\n        </ul>\n      </div>\n    </li>\n  );\n}\n\nfunction FeatureRow({ feature }: { feature: PricingFeature }) {\n  const f = typeof feature === \"string\" ? { label: feature } : feature;\n  const included = f.included !== false;\n\n  return (\n    <li className={cn(\"flex min-w-0 items-start gap-2 text-[12.5px] leading-[18px]\", included ? \"text-fg-2\" : \"text-fg-4\")}>\n      <span className=\"flex h-[18px] shrink-0 items-center\">\n        {included ? <Check size={14} className=\"text-fg\" /> : <Minus size={14} />}\n      </span>\n      <span className=\"min-w-0\">\n        {!included && <span className=\"sr-only\">Not included: </span>}\n        {f.hint != null ? <FeatureHint hint={f.hint}>{f.label}</FeatureHint> : f.label}\n      </span>\n    </li>\n  );\n}\n\n// An infotip, not a tooltip: it opens on hover after a short delay, and also on\n// focus and tap, so touch and screen reader users get the explanation too.\nfunction FeatureHint({ hint, children }: { hint: React.ReactNode; children: React.ReactNode }) {\n  return (\n    <Popover.Root>\n      <Popover.Trigger\n        openOnHover\n        delay={350}\n        closeDelay={80}\n        className={cn(\n          \"cursor-help rounded-[3px] text-left underline decoration-fg-4 decoration-dotted underline-offset-[3px]\",\n          \"transition-[color,text-decoration-color] duration-150 hover:text-fg hover:decoration-fg-3 data-popup-open:text-fg data-popup-open:decoration-fg-3\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        )}\n      >\n        {children}\n      </Popover.Trigger>\n      <Popover.Portal>\n        <Popover.Positioner side=\"top\" align=\"start\" sideOffset={6} collisionPadding={12} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            className={cn(\n              \"max-w-[240px] rounded-lg border border-line-2 bg-raised px-2.5 py-2 text-[12px] leading-[17px] text-fg-2 shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n              \"data-starting-style:translate-y-0.5 data-starting-style:scale-96 data-starting-style:opacity-0\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-100\",\n              \"motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100\",\n            )}\n          >\n            {hint}\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\n// The full-year price, struck through by a line that draws across it.\nfunction Struck({ reduce, children }: { reduce: boolean; children: React.ReactNode }) {\n  return (\n    <s className=\"relative mr-1.5 text-fg-4 no-underline tabular\">\n      <span className=\"sr-only\">was </span>\n      {children}\n      <motion.span\n        aria-hidden\n        className=\"absolute inset-x-[-1px] top-1/2 h-px origin-left bg-fg-3\"\n        initial={reduce ? false : { scaleX: 0 }}\n        animate={{ scaleX: 1 }}\n        transition={{ duration: 0.32, ease: ease.out, delay: 0.14 }}\n      />\n    </s>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/pricing-table"
}
