# Billing 2: change plan with proration

> A change-plan flow: billing interval, plan cards as radios, a seat stepper that respects the current team, and a sticky summary that works out the prorated charge, the credit and what is due today.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/billing-2).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/billing-2
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/billing-2-light-32d30002b314e189.png
- Registry item: https://ui.ballmac.com/r/billing-2.json
- Categories: blocks, billing
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/billing-2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/badge.json`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/segmented-control.json`, `https://ui.ballmac.com/r/i18n.json`, `https://ui.ballmac.com/r/state-view.json`, `https://ui.ballmac.com/r/skeleton.json`

## Files

### `registry/ballmac/components/blocks/billing-2/billing-2.tsx`

```tsx
// Ballmac UI: Billing 2. https://ui.ballmac.com/blocks/billing-2
"use client"

import * as React from "react"
import { Check, Minus, Plus } from "lucide-react"

import { Badge } from "@/components/ballmac/badge"
import { Button } from "@/components/ballmac/button"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
import { useLocale } from "@/lib/ballmac/i18n"

type Billing2Plan = {
  id: string
  name: string
  /** Price per seat per month when billed monthly. */
  monthly: number
  /** Price per seat per month when billed yearly. */
  yearly: number
  description: string
  features: string[]
}

type Billing2Props = Omit<React.ComponentProps<"section">, "title" | "onSubmit"> & StateProps & {
  /** Page heading. */
  title?: string
  /** One line under the heading. */
  description?: string
  /** Plans to choose from. */
  plans?: Billing2Plan[]
  /** Id of the plan the account is on now. */
  currentPlan?: string
  /** Plan selected when the page opens. Defaults to the plan after the current one, so the summary shows an upgrade. */
  defaultPlan?: string
  /** Seats the account has now. */
  currentSeats?: number
  /** How it is billed now. */
  currentInterval?: "monthly" | "yearly"
  /** Today, as an ISO date. Passed in so the proration is the same on the server and in the browser. */
  today?: string
  /** The date the current period ends and the next one starts, as an ISO date. */
  renewsOn?: string
  /** Seats can't go below this (people already on the team). */
  minSeats?: number
  /** ISO currency code. */
  currency?: string
  /** Locale for numbers and dates. Fixed by default so server and browser match. */
  locale?: string
  /** Called with the choice when it is confirmed. Throw to show an error; resolve to show the confirmation. */
  onConfirm?: (change: { plan: string; seats: number; interval: "monthly" | "yearly" }) => void | Promise<void>
}

const defaultPlans: Billing2Plan[] = [
  { id: "starter", name: "Starter", monthly: 12, yearly: 9, description: "For small teams getting started.", features: ["3 projects", "10 GB storage", "Community support"] },
  { id: "pro", name: "Pro", monthly: 32, yearly: 25, description: "For teams that ship every week.", features: ["Unlimited projects", "1 TB storage", "Roles and SSO", "Priority support"] },
  { id: "scale", name: "Scale", monthly: 79, yearly: 63, description: "For security and volume needs.", features: ["Audit log and SCIM", "99.99% uptime SLA", "Dedicated manager", "Custom retention"] },
]

const DAY = 86400000
const utc = (iso: string) => Date.parse(`${iso}T00:00:00Z`)

function Billing2Inner({
  title = "Change your plan",
  description = "Pick a plan and seats. You only pay the difference for the days left in this period.",
  plans = defaultPlans,
  currentPlan = "pro",
  defaultPlan,
  currentSeats = 7,
  currentInterval = "monthly",
  today = "2026-10-01",
  renewsOn = "2026-10-28",
  minSeats = 7,
  currency = "USD",
  locale,
  onConfirm,
  className,
  ...props
}: Billing2Props) {
  const defaultLocale = useLocale()
  locale ??= defaultLocale
  const [planId, setPlanId] = React.useState(defaultPlan ?? plans[plans.findIndex((p) => p.id === currentPlan) + 1]?.id ?? currentPlan)
  const [seats, setSeats] = React.useState(currentSeats)
  const [period, setPeriod] = React.useState<"monthly" | "yearly">(currentInterval)
  const [state, setState] = React.useState<"idle" | "saving" | "error" | "done">("idle")
  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: 2 }), [locale, currency])
  const whole = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }), [locale, currency])
  const date = React.useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }), [locale])

  const current = plans.find((p) => p.id === currentPlan) ?? plans[0]
  const next = plans.find((p) => p.id === planId) ?? plans[0]
  if (!current || !next) return null

  const lengthOf = (i: "monthly" | "yearly") => (i === "yearly" ? 365 : 30)
  const daysLeft = Math.max(0, Math.min(lengthOf(currentInterval), Math.round((utc(renewsOn) - utc(today)) / DAY)))
  const perSeat = (p: Billing2Plan, i: "monthly" | "yearly") => (i === "yearly" ? p.yearly * 12 : p.monthly)
  const currentCost = perSeat(current, currentInterval) * currentSeats
  const nextCost = perSeat(next, period) * seats
  // Both sides are charged by the day for the days left in the current period.
  const credit = (currentCost / lengthOf(currentInterval)) * daysLeft
  const charge = (nextCost / lengthOf(period)) * daysLeft
  const dueToday = Math.max(0, charge - credit)
  const unchanged = planId === currentPlan && seats === currentSeats && period === currentInterval
  const direction = next === current ? (seats > currentSeats ? "Add seats" : "Update seats") : plans.indexOf(next) > plans.indexOf(current) ? `Upgrade to ${next.name}` : `Switch to ${next.name}`

  async function confirm() {
    setState("saving")
    try {
      if (onConfirm) await onConfirm({ plan: planId, seats, interval: period })
      else await new Promise((r) => setTimeout(r, 900))
      setState("done")
    } catch {
      setState("error")
    }
  }

  return (
    <section data-slot="billing-2" className={cn("mx-auto w-full max-w-5xl px-4 py-8 sm:px-6", className)} {...props}>
      <h2 className="text-2xl font-semibold tracking-[-0.03em]">{title}</h2>
      <p className="text-muted-foreground mt-1 text-sm">{description}</p>

      {state === "done" ? (
        <div role="status" className="bg-card mt-8 flex flex-col items-center rounded-3xl border px-6 py-16 text-center">
          <span aria-hidden="true" className="bg-chart-2/15 flex size-14 items-center justify-center rounded-full"><Check className="size-7" /></span>
          <h3 className="mt-6 text-2xl font-semibold tracking-[-0.03em]">You’re on {next.name}</h3>
          <p className="text-muted-foreground mt-2 max-w-sm text-pretty">{seats} seats, billed {period}. {dueToday > 0 ? `We charged ${money.format(dueToday)} today.` : "There is nothing to pay today."} Your next invoice is on {date.format(new Date(utc(renewsOn)))}.</p>
          <Button variant="outline" shape="pill" className="mt-8" onClick={() => setState("idle")}>Make another change</Button>
        </div>
      ) : (
        <div className="mt-8 grid gap-6 lg:grid-cols-[1fr_21rem]">
          <div className="min-w-0 space-y-6">
            <div>
              <p className="mb-2 text-sm font-medium">Billing</p>
              <SegmentedControl aria-label="Billing interval" value={period} onValueChange={(v) => setPeriod(v as "monthly" | "yearly")}>
                <SegmentedControlItem value="monthly">Monthly</SegmentedControlItem>
                <SegmentedControlItem value="yearly">Yearly · save 20%</SegmentedControlItem>
              </SegmentedControl>
            </div>

            <fieldset>
              <legend className="mb-2 text-sm font-medium">Plan</legend>
              <div className="grid gap-3">
                {plans.map((p) => {
                  const on = planId === p.id
                  const price = period === "yearly" ? p.yearly : p.monthly
                  return (
                    <label key={p.id} className="relative cursor-pointer">
                      <input type="radio" name="billing-2-plan" value={p.id} checked={on} onChange={() => setPlanId(p.id)} className="peer sr-only" />
                      <span className="peer-focus-visible:ring-ring/50 peer-checked:border-foreground peer-checked:bg-accent/50 hover:bg-accent/30 bg-card flex flex-col gap-4 rounded-2xl border p-5 transition-colors peer-focus-visible:ring-[3px] sm:flex-row sm:items-center">
                        <span className="flex flex-1 items-start gap-3">
                          <span aria-hidden="true" className={cn("mt-1 flex size-5 shrink-0 items-center justify-center rounded-full border", on && "bg-foreground text-background border-transparent")}>{on && <Check className="size-3" />}</span>
                          <span>
                            <span className="flex items-center gap-2 font-semibold">{p.name}{p.id === currentPlan && <Badge variant="outline">Current</Badge>}</span>
                            <span className="text-muted-foreground mt-0.5 block text-sm">{p.description}</span>
                            <span className="text-muted-foreground mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">{p.features.map((f) => <span key={f} className="flex items-center gap-1"><Check className="size-3" aria-hidden="true" />{f}</span>)}</span>
                          </span>
                        </span>
                        <span className="sm:text-end">
                          <span className="text-2xl font-semibold tracking-[-0.03em] tabular-nums">{whole.format(price)}</span>
                          <span className="text-muted-foreground block text-xs">per seat / month</span>
                        </span>
                      </span>
                    </label>
                  )
                })}
              </div>
            </fieldset>

            <div>
              <p id="billing-2-seats" className="mb-2 text-sm font-medium">Seats</p>
              <div role="group" aria-labelledby="billing-2-seats" className="inline-flex items-center rounded-xl border">
                <Button variant="ghost" size="icon" aria-label="Remove a seat" disabled={seats <= minSeats} onClick={() => setSeats((s) => Math.max(minSeats, s - 1))}><Minus /></Button>
                <span role="status" aria-label={`${seats} seats`} className="w-14 text-center text-sm font-semibold tabular-nums">{seats}</span>
                <Button variant="ghost" size="icon" aria-label="Add a seat" onClick={() => setSeats((s) => s + 1)}><Plus /></Button>
              </div>
              <p className="text-muted-foreground mt-2 text-xs">{minSeats} people are on the team now, so you can’t go below {minSeats}.</p>
            </div>
          </div>

          <aside aria-label="Order summary" className="bg-card h-fit rounded-2xl border p-5 lg:sticky lg:top-6">
            <p className="text-sm font-medium">Summary</p>
            <dl className="mt-4 space-y-3 text-sm">
              <div className="flex justify-between gap-3"><dt className="text-muted-foreground">{next.name} × {seats} seats</dt><dd className="tabular-nums">{money.format(nextCost)} / {period === "yearly" ? "yr" : "mo"}</dd></div>
              <div className="flex justify-between gap-3"><dt className="text-muted-foreground">New cost for {daysLeft} remaining days</dt><dd className="tabular-nums">{money.format(charge)}</dd></div>
              <div className="flex justify-between gap-3"><dt className="text-muted-foreground">Credit for your current plan</dt><dd className="tabular-nums">−{money.format(credit)}</dd></div>
              <div className="flex justify-between gap-3 border-t pt-3 text-base font-semibold"><dt>Due today</dt><dd className="tabular-nums">{money.format(dueToday)}</dd></div>
            </dl>
            <p className="text-muted-foreground mt-3 text-xs">From {date.format(new Date(utc(renewsOn)))} you’ll pay {money.format(nextCost)} per {period === "yearly" ? "year" : "month"}. Cancel any time.</p>
            {state === "error" && <p role="alert" className="text-destructive mt-3 text-sm">We couldn’t update your plan. Please try again.</p>}
            <Button size="lg" shape="pill" className="mt-5 w-full" disabled={unchanged} loading={state === "saving"} onClick={confirm}>{unchanged ? "Choose a change" : direction}</Button>
          </aside>
        </div>
      )}
    </section>
  )
}


/** The same shapes as the ready view, so nothing jumps when data arrives. */
function Billing2Skeleton() {
  return (
    <div className="grid gap-6 lg:grid-cols-[1fr_21rem]" aria-hidden="true">
      <div className="min-w-0 space-y-6">
        <div className="grid gap-3 sm:grid-cols-3">
          {[0, 1, 2].map((i) => (
            <div key={i} className="bg-card grid gap-3 rounded-2xl border p-5">
              <Skeleton className="h-5 w-20" />
              <Skeleton className="h-8 w-24" />
              <Skeleton className="h-3.5 w-full" />
              <Skeleton className="h-3.5 w-4/5" />
            </div>
          ))}
        </div>
        <div className="bg-card grid gap-4 rounded-2xl border p-5">
          <Skeleton className="h-4 w-20" />
          <Skeleton className="h-10 w-full rounded-lg" />
        </div>
      </div>
      <div className="bg-card grid content-start gap-4 rounded-2xl border p-5">
        <Skeleton className="h-4 w-28" />
        {[0, 1, 2].map((i) => (
          <div key={i} className="flex justify-between">
            <Skeleton className="h-3.5 w-28" />
            <Skeleton className="h-3.5 w-14" />
          </div>
        ))}
        <Skeleton className="mt-2 h-10 w-full rounded-lg" />
      </div>
    </div>
  )
}

/**
 * With `state="loading"` this shows a skeleton of its layout under the real heading, with `state="error"` an error with retry, and with `state="empty"` a message that there is nothing to show yet.
 */
function Billing2({ state = "ready", onRetry, emptyState, errorState, title = "Change your plan", description = "Pick a plan and seats. You only pay the difference for the days left in this period.", className, ...props }: Billing2Props) {
  if (state === "ready") return <Billing2Inner title={title} description={description} className={className} {...props} />
  return (
    <section data-slot="billing-2" data-state={state} className={cn("mx-auto w-full max-w-5xl px-4 py-8 sm:px-6", className)} {...frameProps(props)}>
      <h2 className="text-2xl font-semibold tracking-[-0.03em]">{title}</h2>
      <p className="text-muted-foreground mt-1 text-sm">{description}</p>
      <StateView
        state={state}
        className="mt-8"
        loading={<Billing2Skeleton />}
        label="Loading plans"
        empty={{ title: 'No plans available', description: 'There are no plans to choose from right now.', ...emptyState }}
        error={{ title: 'Could not load the plans', description: 'The pricing service did not answer. Nothing has been changed.', ...errorState }}
        onRetry={onRetry}
      >
        {null}
      </StateView>
    </section>
  )
}

export { Billing2, type Billing2Props, type Billing2Plan }
```
