{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plan-selector",
  "title": "Plan Selector",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Pricing plan cards that work as a radio group, with a Monthly/Yearly switch that re-prices every plan, a highlighted plan, feature lists and form submission.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/plan-selector.tsx",
      "content": "// Ballmac UI: Plan Selector. https://ui.ballmac.com/components/plan-selector\n\"use client\";\n\nimport * as React from \"react\";\nimport { Check, Sparkles } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useLocale, useMessages } from \"@/lib/ballmac/i18n\";\n\ntype PlanOption = {\n  /** Unique id; this is the selected value. */\n  id: string;\n  /** Plan name. */\n  name: string;\n  /** One line describing who it is for. */\n  description?: string;\n  /** Price per month. `yearly` is the monthly price when billed yearly. Use 0 for free. */\n  price: { monthly: number; yearly: number };\n  /** Short list of what is included. */\n  features: string[];\n  /** Ribbon text that makes the plan stand out, for example \"Most popular\". */\n  highlight?: string;\n  /** Text after the price, for example \"per user\". */\n  priceNote?: string;\n  /** Prevent choosing this plan. */\n  disabled?: boolean;\n};\n\ntype Billing = \"monthly\" | \"yearly\";\n\ntype PlanSelectorProps = Omit<React.ComponentProps<\"fieldset\">, \"onChange\" | \"defaultValue\" | \"value\"> & {\n  /** Plans to offer. */\n  plans: PlanOption[];\n  /** Controlled selected plan id. */\n  value?: string;\n  /** Initial plan id when uncontrolled. */\n  defaultValue?: string;\n  /** Called with the plan id when one is chosen. */\n  onValueChange?: (id: string) => void;\n  /** Controlled billing period. */\n  billing?: Billing;\n  /** Initial billing period. */\n  defaultBilling?: Billing;\n  /** Called when the billing period changes. */\n  onBillingChange?: (billing: Billing) => void;\n  /** Name of the radio group, so the value submits with a native form. */\n  name?: string;\n  /** ISO 4217 currency code. */\n  currency?: string;\n  /** Locale for money. Fixed by default so server and browser match. */\n  locale?: string;\n  /** Visible legend for the group. */\n  legend?: string;\n};\n\n/**\n * Pick a pricing plan. Each plan is a real radio button, so arrow keys, Space and form submission work, and screen\n * readers announce \"Team, 2 of 3\". A Monthly/Yearly switch re-prices every plan with a short slide.\n */\nfunction PlanSelector({\n  plans,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  billing: billingProp,\n  defaultBilling = \"yearly\",\n  onBillingChange,\n  name,\n  currency = \"USD\",\n  locale,\n  legend = \"Choose a plan\",\n  className,\n  ...props\n}: PlanSelectorProps) {\n  const defaultLocale = useLocale()\n  locale ??= defaultLocale\n  const msg = useMessages()\n  const reduce = useReducedMotion();\n  const generated = React.useId();\n  const groupName = name ?? `plan-${generated}`;\n  const [innerValue, setInnerValue] = React.useState(defaultValue ?? plans.find((p) => p.highlight)?.id ?? plans[0]?.id);\n  const value = valueProp ?? innerValue;\n  const [innerBilling, setInnerBilling] = React.useState<Billing>(defaultBilling);\n  const billing = billingProp ?? innerBilling;\n  const setBilling = (next: Billing) => {\n    if (billingProp === undefined) setInnerBilling(next);\n    onBillingChange?.(next);\n  };\n  const money = (n: number) =>\n    new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: n % 1 ? 2 : 0 }).format(n);\n  const savings = Math.max(\n    0,\n    ...plans.filter((p) => p.price.monthly > 0).map((p) => Math.round((1 - p.price.yearly / p.price.monthly) * 100)),\n  );\n\n  return (\n    <fieldset data-slot=\"plan-selector\" className={cn(\"grid w-full min-w-0 gap-6\", className)} {...props}>\n      <legend className=\"sr-only\">{legend}</legend>\n\n      <div className=\"flex justify-center\">\n        <div\n          role=\"radiogroup\"\n          aria-label={msg(\"plan-selector.billingPeriod\", \"Billing period\")}\n          className=\"relative inline-flex rounded-full border bg-muted/60 p-1 text-sm font-medium\"\n        >\n          {([\"monthly\", \"yearly\"] as const).map((period) => (\n            <label key={period} className=\"relative cursor-pointer\">\n              <input\n                type=\"radio\"\n                name={`${groupName}-billing`}\n                value={period}\n                checked={billing === period}\n                onChange={() => setBilling(period)}\n                className=\"peer sr-only\"\n              />\n              {billing === period && (\n                <motion.span\n                  layoutId={`${groupName}-billing-pill`}\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-0 rounded-full bg-background shadow-[0_1px_3px_rgb(0_0_0/0.12)]\"\n                  transition={reduce ? { duration: 0 } : spring.snappy}\n                />\n              )}\n              <span\n                className={cn(\n                  \"relative flex h-8 items-center gap-2 rounded-full px-4 transition-colors peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50\",\n                  billing === period ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                {period === \"monthly\" ? \"Monthly\" : \"Yearly\"}\n                {period === \"yearly\" && savings > 0 && (\n                  <span className=\"rounded-full bg-chart-2/20 px-1.5 py-px text-[10px] font-semibold text-foreground\">{msg(\"plan-selector.save\", \"Save\")} {savings}%</span>\n                )}\n              </span>\n            </label>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"grid gap-4 md:grid-cols-[repeat(auto-fit,minmax(14.5rem,1fr))]\">\n        {plans.map((plan) => {\n          const price = plan.price[billing];\n          const free = plan.price.monthly === 0;\n          const selected = plan.id === value;\n          return (\n            <label\n              key={plan.id}\n              data-selected={selected || undefined}\n              className={cn(\n                \"group/plan relative flex cursor-pointer flex-col rounded-2xl border bg-card p-6 text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color,box-shadow,transform] duration-200\",\n                \"hover:border-foreground/25 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60 motion-reduce:transition-none\",\n                plan.highlight && \"md:-translate-y-2 md:shadow-[0_24px_48px_-24px_rgb(0_0_0/0.3)]\",\n                selected && \"border-primary shadow-[0_0_0_1px_var(--primary),0_16px_40px_-20px_rgb(0_0_0/0.3)]\",\n              )}\n            >\n              <input\n                type=\"radio\"\n                name={groupName}\n                value={plan.id}\n                checked={selected}\n                disabled={plan.disabled}\n                onChange={() => {\n                  if (valueProp === undefined) setInnerValue(plan.id);\n                  onValueChange?.(plan.id);\n                }}\n                className=\"peer sr-only\"\n              />\n              {plan.highlight && (\n                <span className=\"absolute -top-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 rounded-full bg-primary px-3 py-1 text-[11px] font-semibold tracking-wide whitespace-nowrap text-primary-foreground shadow-sm\">\n                  <Sparkles aria-hidden=\"true\" className=\"size-3\" />\n                  {plan.highlight}\n                </span>\n              )}\n              <span className=\"flex items-start justify-between gap-3\">\n                <span>\n                  <span className=\"block text-base font-semibold tracking-tight\">{plan.name}</span>\n                  {plan.description && <span className=\"mt-1 block text-[13px] leading-snug text-muted-foreground\">{plan.description}</span>}\n                </span>\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors\",\n                    selected ? \"border-primary bg-primary text-primary-foreground\" : \"border-input\",\n                  )}\n                >\n                  {selected && <Check className=\"size-3\" strokeWidth={3} />}\n                </span>\n              </span>\n\n              <span className=\"mt-5 flex items-baseline gap-1.5\">\n                <span className=\"relative inline-flex h-10 items-baseline overflow-hidden text-4xl font-semibold tracking-tight tabular-nums\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <motion.span\n                      key={`${plan.id}-${billing}`}\n                      initial={reduce ? false : { y: \"60%\", opacity: 0 }}\n                      animate={{ y: 0, opacity: 1 }}\n                      exit={reduce ? { opacity: 0 } : { y: \"-60%\", opacity: 0 }}\n                      transition={reduce ? { duration: 0 } : spring.snappy}\n                    >\n                      {free ? \"Free\" : money(price)}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                {!free && (\n                  <span className=\"text-[13px] text-muted-foreground\">\n                    {msg(\"plan-selector.perMonth\", \"/ month\")}{plan.priceNote ? ` ${plan.priceNote}` : \"\"}\n                  </span>\n                )}\n              </span>\n              <span className=\"mt-1 block h-4 text-xs text-muted-foreground\">\n                {!free && billing === \"yearly\" ? `Billed ${money(price * 12)} per year` : !free ? \"Billed monthly\" : \"No card needed\"}\n              </span>\n\n              <span className=\"my-5 block h-px bg-border\" aria-hidden=\"true\" />\n              <ul className=\"grid gap-2.5 text-sm\">\n                {plan.features.map((f) => (\n                  <li key={f} className=\"flex items-start gap-2.5\">\n                    <Check aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-chart-2\" strokeWidth={2.5} />\n                    <span>{f}</span>\n                  </li>\n                ))}\n              </ul>\n            </label>\n          );\n        })}\n      </div>\n    </fieldset>\n  );\n}\n\nexport { PlanSelector, type PlanSelectorProps, type PlanOption, type Billing as PlanBilling };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/plan-selector.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "pricing",
      "plans",
      "radio",
      "billing toggle"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "plans: {id,name,price:{monthly,yearly},features,highlight,disabled}. value/onValueChange pick a plan; billing/onBillingChange switch period; name submits with a form.",
      "whenToUse": [
        "Upgrade and signup flows",
        "Pricing sections where visitors choose a plan"
      ],
      "whenNotToUse": [
        "A feature comparison grid; use comparison-table",
        "Displaying the current plan; use billing-card"
      ],
      "composesWith": [
        "billing-card",
        "comparison-table",
        "radio-group"
      ],
      "a11y": [
        {
          "keys": "Arrow keys / Space",
          "action": "Native radio behavior between plans and the billing switch"
        },
        {
          "keys": "Screen readers",
          "action": "Announced as a radio group with position, name and price"
        },
        {
          "keys": "Reduced motion",
          "action": "Price changes swap instantly"
        }
      ],
      "customization": [
        "highlight ribbon",
        "priceNote",
        "currency and locale",
        "disabled plans"
      ]
    },
    "examples": [
      "plan-selector-demo",
      "plan-selector-states"
    ],
    "url": "https://ui.ballmac.com/components/plan-selector"
  },
  "categories": [
    "saas"
  ],
  "type": "registry:ui"
}