Plan picker
ProPicks a plan, seats and a billing period, with a summary of the price, what is due today, and whether the change is an upgrade or a downgrade. Prices and proration come from your billing backend.
npx prodular-ui add plan-pickerPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Change a subscription
On Growth with 10 seats and 8 in use. Pick Scale to see the credit and what is due today; pick Starter for a downgrade at the end of the period.
- 100 invoices a month
- Card and ACH payments
- Automatic reminders
Up to 5 seats. 8 people use one today.
- 2,500 invoices a month
- Approval workflows
- Ledger sync every hour
- Unlimited invoices
- Multiple entities
- Audit log and SSO
"use client"
import * as React from "react"
import { PlanPicker, type PlanSelection, type PricingPlan, type ProrationPreview } from "@/components/ui/plan-picker"
const plans: PricingPlan[] = [ { id: "starter", name: "Starter", description: "Invoices and payments for a small team.", price: { monthly: 19, yearly: 16 }, maxSeats: 5, features: ["100 invoices a month", "Card and ACH payments", "Automatic reminders"], }, { id: "growth", name: "Growth", description: "Collections, approvals and spend in one place.", price: { monthly: 39, yearly: 32 }, badge: "Most popular", features: ["2,500 invoices a month", "Approval workflows", "Ledger sync every hour"], }, { id: "scale", name: "Scale", description: "Several entities, currencies and audits.", price: { monthly: 69, yearly: 57 }, features: ["Unlimited invoices", "Multiple entities", "Audit log and SSO"], },]
const current: PlanSelection = { planId: "growth", seats: 10, period: "monthly" }// 12 of 30 days left in the period. A real app asks its billing provider for this preview.const left = 12 / 30const renewal = new Date(Date.UTC(2026, 10, 3))
function preview(next: PlanSelection): ProrationPreview { const per = (s: PlanSelection) => (plans.find((p) => p.id === s.planId)?.price[s.period] ?? 0) * s.seats * (s.period === "yearly" ? 12 : 1) const now = per(current) const then = per(next) if (then < now && next.period === current.period) return { dueToday: 0, effective: "period_end", nextInvoice: { date: renewal, amount: then } } const credit = Math.round(now * left * 100) / 100 const charge = next.period === "yearly" ? then : Math.round(then * left * 100) / 100 return { credit, dueToday: Math.max(0, Math.round((charge - credit) * 100) / 100), effective: "now", nextInvoice: { date: renewal, amount: then } }}
export default function PlanPickerChangePlan() { const [value, setValue] = React.useState<PlanSelection>({ planId: "scale", seats: 10, period: "monthly" }) const [loading, setLoading] = React.useState(false) const [shown, setShown] = React.useState(() => preview({ planId: "scale", seats: 10, period: "monthly" }))
const change = (next: PlanSelection) => { setValue(next) setLoading(true) // Stands in for a request to the billing provider. setTimeout(() => { setShown(preview(next)) setLoading(false) }, 300) }
return ( <PlanPicker plans={plans} value={value} onValueChange={change} current={current} seatsInUse={8} proration={shown} prorationLoading={loading} yearlyNote="Save about 17% yearly" timeZone="UTC" onConfirm={() => new Promise<void>((r) => setTimeout(r, 900))} /> )}First purchase
Without current it is a checkout. Enterprise has no price and offers Talk to sales.
- 2,500 invoices a month
- Approval workflows
- Ledger sync every hour
- Unlimited invoices
- Multiple entities
- Audit log and SSO
- Custom limits
- SAML and SCIM
- 99.9% uptime SLA
"use client"
import * as React from "react"
import { PlanPicker, type PlanSelection, type PricingPlan } from "@/components/ui/plan-picker"
const plans: PricingPlan[] = [ { id: "starter", name: "Starter", description: "Invoices and payments for a small team.", price: { monthly: 19, yearly: 16 }, maxSeats: 5, features: ["100 invoices a month", "Card and ACH payments", "Automatic reminders"], }, { id: "growth", name: "Growth", description: "Collections, approvals and spend in one place.", price: { monthly: 39, yearly: 32 }, badge: "Most popular", features: ["2,500 invoices a month", "Approval workflows", "Ledger sync every hour"], }, { id: "scale", name: "Scale", description: "Several entities, currencies and audits.", price: { monthly: 69, yearly: 57 }, features: ["Unlimited invoices", "Multiple entities", "Audit log and SSO"], },]
const withEnterprise: PricingPlan[] = [ ...plans.slice(1), { id: "enterprise", name: "Enterprise", description: "Contracts, invoicing terms and a named contact.", price: { monthly: null, yearly: null }, features: ["Custom limits", "SAML and SCIM", "99.9% uptime SLA"] },]
export default function PlanPickerCheckout() { const [value, setValue] = React.useState<PlanSelection>({ planId: "growth", seats: 5, period: "yearly" }) return ( <PlanPicker plans={withEnterprise} value={value} onValueChange={setValue} yearlyNote="2 months free" onConfirm={() => new Promise<void>((r) => setTimeout(r, 700))} onContactSales={() => {}} /> )}Anatomy
<PlanPicker plans={plans} value={selection} onValueChange={setSelection} current={subscription} seatsInUse={members.length} proration={preview} onConfirm={changePlan}/>Guidance
Use it when
- Billing settings where people change plan, seats or period, and in-app checkout.
- proration from your billing provider's preview endpoint, so Due today matches the invoice.
- seatsInUse so nobody buys fewer seats than people who already sign in.
Avoid
- Marketing pricing pages. Use the pricing block, which sells; this one changes a subscription.
- Working out proration in the browser for real charges. Show what the backend returns.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Prices are per seat per month for each period; yearly is billed as twelve months.
- Keep the selection in state and, when it changes, fetch a proration preview from the billing provider (for example Stripe or Polar upcoming invoice) and pass it with prorationLoading while it loads.
- Pass current for existing subscriptions. The button says Upgrade, Downgrade, Switch or Update, and is disabled when nothing changed.
- Downgrades that wait for renewal return effective "period_end" and nextInvoice; the summary explains when the change happens.
- Throw from onConfirm with a clear message on payment failures; nothing else changes.
API
PlanPicker
| Prop | Type | Default | Description |
|---|---|---|---|
| plans | PricingPlan[] | { id, name, description?, price: { monthly, yearly }, badge?, features?, minSeats?, maxSeats? }. A null price means talk to sales. | |
| value / onValueChange | { planId, seats, period } | The selection, controlled. | |
| current | PlanSelection | The subscription today. | |
| seatsInUse | number | 0 | Minimum seats. |
| proration / prorationLoading | { credit?, dueToday, nextInvoice?, effective? } / boolean | What the change costs today. | |
| onConfirm / onContactSales | (value) => Promise<void> / (plan) => void | Applies the change, or contacts sales for custom plans. | |
| currency / locale / timeZone | string | "USD" / "en-US" | Formatting. |
| yearlyNote | string | Beside the period switch, like Save 17%. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add plan-picker# or, with the registry in components.jsonnpx shadcn@latest add @prodular/plan-picker