# Pricing 4: one-time license vs subscription

> A pricing section for Mac apps that sell both ways: a Buy once / Subscribe switch, license size cards and a cumulative-cost chart that computes the break-even year.

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

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

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/pricing-4.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`, `recharts`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/chart.json`, `https://ui.ballmac.com/r/label.json`, `https://ui.ballmac.com/r/radio-group.json`, `https://ui.ballmac.com/r/segmented-control.json`, `https://ui.ballmac.com/r/switch.json`

## Files

### `registry/ballmac/components/blocks/pricing-4/pricing-4.tsx`

```tsx
// Ballmac UI: Pricing 4. https://ui.ballmac.com/blocks/pricing-4
"use client"

import * as React from "react"
import { Check, ShieldCheck } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"

import { buttonVariants } from "@/components/ballmac/button"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ballmac/chart"
import { Label } from "@/components/ballmac/label"
import { RadioGroup, RadioGroupOption } from "@/components/ballmac/radio-group"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Switch } from "@/components/ballmac/switch"
import { cn } from "@/lib/utils"

type Pricing4Tier = {
  /** Stable key, e.g. "solo". */
  id: string
  /** Tier name, e.g. "1 Mac". */
  name: string
  /** One short line, e.g. "For your own Mac". */
  description?: string
  /** One-time price for this tier, in whole currency units. */
  once: number
  /** Subscription price per year for this tier. */
  yearly: number
}

type Pricing4Mode = "once" | "subscribe"

type Pricing4Props = Omit<React.ComponentProps<"section">, "title"> & {
  /** Section heading. */
  title?: string
  /** One sentence under the heading. */
  description?: string
  /** License sizes to choose from. */
  tiers?: Pricing4Tier[]
  /** Which tier starts selected (an `id`). Defaults to the second tier. */
  defaultTier?: string
  /** Which way to pay starts selected. */
  defaultMode?: Pricing4Mode
  /** Yearly price of optional updates after the first year of a one-time license, per tier id. A single number applies to every tier. */
  renewal?: number | Record<string, number>
  /** Number of years the cost chart covers (2 to 8). */
  years?: number
  /** ISO 4217 currency code. */
  currency?: string
  /** What the one-time license includes. */
  onceIncludes?: string[]
  /** What the subscription includes. */
  subscribeIncludes?: string[]
  /** Where the buy button goes. Receives the tier id and the way to pay. */
  href?: (tierId: string, mode: Pricing4Mode) => string
  /** Money-back line under the button. Pass null to hide it. */
  guarantee?: string | null
}

const defaultTiers: Pricing4Tier[] = [
  { id: "solo", name: "1 Mac", description: "Just for you", once: 49, yearly: 39 },
  { id: "trio", name: "3 Macs", description: "Desk, laptop and studio", once: 99, yearly: 79 },
  { id: "five", name: "5 Macs", description: "A small team", once: 149, yearly: 119 },
]

const defaultOnce = ["Yours to keep, no account needed", "A full year of updates", "Every Mac you license, forever"]
const defaultSubscribe = ["Every update, always current", "Priority email support", "Cancel any time, keep your data"]

const MODE_COPY: Record<Pricing4Mode, { label: string; cta: string; period: string }> = {
  once: { label: "Buy once", cta: "Buy a license", period: "one payment" },
  subscribe: { label: "Subscribe", cta: "Start subscription", period: "per year" },
}

function Pricing4({
  title = "Pay once, or stay current.",
  description = "Own it outright with a one-time license, or subscribe for every update. See exactly when each one pays off.",
  tiers = defaultTiers,
  defaultTier,
  defaultMode = "once",
  renewal = 19,
  years: yearsProp = 5,
  currency = "USD",
  onceIncludes = defaultOnce,
  subscribeIncludes = defaultSubscribe,
  href = () => "#",
  guarantee = "30-day money-back guarantee",
  className,
  ...props
}: Pricing4Props) {
  const years = Math.min(8, Math.max(2, Math.round(yearsProp)))
  const [mode, setMode] = React.useState<Pricing4Mode>(defaultMode)
  const [tierId, setTierId] = React.useState(defaultTier ?? tiers[1]?.id ?? tiers[0]?.id ?? "")
  const [keepUpdates, setKeepUpdates] = React.useState(false)
  const baseId = React.useId()

  const tier = tiers.find((t) => t.id === tierId) ?? tiers[0]
  const money = React.useMemo(() => new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 }), [currency])

  if (!tier) return null

  const renew = typeof renewal === "number" ? renewal : (renewal[tier.id] ?? 0)
  const onceAt = (y: number) => tier.once + (keepUpdates ? renew * (y - 1) : 0)
  const subAt = (y: number) => tier.yearly * y
  const data = Array.from({ length: years }, (_, i) => ({ year: `Year ${i + 1}`, once: onceAt(i + 1), subscribe: subAt(i + 1) }))
  const breakEven = data.findIndex((_, i) => subAt(i + 1) >= onceAt(i + 1))
  const breakYear = breakEven === -1 ? null : breakEven + 1
  const saved = subAt(years) - onceAt(years)

  const headline =
    breakYear === null
      ? `Subscribing costs less than buying over ${years} years`
      : breakYear === 1
        ? "A one-time license is cheaper from day one"
        : `A one-time license pays for itself in year ${breakYear}`
  const detail =
    breakYear === null
      ? `The subscription totals ${money.format(subAt(years))} against ${money.format(onceAt(years))} for the license, so it is the cheaper way to pay.`
      : `By year ${years} you will have paid ${money.format(onceAt(years))} instead of ${money.format(subAt(years))}${saved > 0 ? `, saving ${money.format(saved)}` : ""}.`

  const config = {
    once: { label: keepUpdates ? "Buy once, with updates" : "Buy once", color: "var(--chart-1)" },
    subscribe: { label: "Subscribe", color: "var(--chart-3)" },
  } satisfies ChartConfig

  const price = mode === "once" ? tier.once : tier.yearly
  const includes = mode === "once" ? onceIncludes : subscribeIncludes
  const modeLabelId = `${baseId}-mode`
  const tierLabelId = `${baseId}-tier`

  return (
    <section data-slot="pricing-4" className={cn("mx-auto max-w-6xl px-4 py-20 sm:px-6 md:py-28", className)} {...props}>
      <div className="mx-auto max-w-2xl text-center">
        <h2 className="text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl lg:text-5xl">{title}</h2>
        <p className="text-muted-foreground mt-4 text-lg text-pretty">{description}</p>
      </div>

      <div className="mt-12 grid items-start gap-5 lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]">
        <div className="bg-card rounded-3xl border p-5 shadow-sm sm:p-6">
          <p id={modeLabelId} className="sr-only">Way to pay</p>
          <SegmentedControl aria-labelledby={modeLabelId} value={mode} onValueChange={(v) => setMode(v as Pricing4Mode)} fullWidth>
            <SegmentedControlItem value="once">{MODE_COPY.once.label}</SegmentedControlItem>
            <SegmentedControlItem value="subscribe">{MODE_COPY.subscribe.label}</SegmentedControlItem>
          </SegmentedControl>

          <p id={tierLabelId} className="text-muted-foreground mt-6 text-xs font-semibold tracking-wide uppercase">License size</p>
          <RadioGroup aria-labelledby={tierLabelId} value={tier.id} onValueChange={setTierId} className="mt-2 grid gap-2">
            {tiers.map((t) => (
              <RadioGroupOption key={t.id} value={t.id} title={t.name} description={t.description}>
                <span className="text-sm font-semibold tabular-nums">{money.format(mode === "once" ? t.once : t.yearly)}</span>
              </RadioGroupOption>
            ))}
          </RadioGroup>

          <div className="mt-6 flex items-baseline gap-2" aria-live="polite">
            <span className="text-5xl font-semibold tracking-[-0.045em] tabular-nums">{money.format(price)}</span>
            <span className="text-muted-foreground text-sm">{MODE_COPY[mode].period}</span>
          </div>
          <ul className="mt-4 grid gap-2">
            {includes.map((item) => (
              <li key={item} className="flex items-start gap-2 text-sm">
                <Check className="text-chart-2 mt-0.5 size-4 shrink-0" aria-hidden="true" />
                {item}
              </li>
            ))}
          </ul>
          <a href={href(tier.id, mode)} className={buttonVariants({ shape: "pill", size: "lg", className: "mt-6 w-full" })}>
            {MODE_COPY[mode].cta}
          </a>
          {guarantee && (
            <p className="text-muted-foreground mt-3 flex items-center justify-center gap-1.5 text-xs">
              <ShieldCheck className="size-3.5" aria-hidden="true" />
              {guarantee}
            </p>
          )}
        </div>

        <div className="bg-card rounded-3xl border p-5 sm:p-6">
          <p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">What you pay over time · {tier.name}</p>
          <h3 className="mt-2 text-xl font-semibold tracking-tight text-balance sm:text-2xl" aria-live="polite">{headline}</h3>
          <p className="text-muted-foreground mt-1.5 text-sm text-pretty">{detail}</p>

          <ChartContainer
            config={config}
            label={`Total cost over ${years} years: one-time license against subscription`}
            summary={`${headline}. ${detail}`}
            className="mt-5 aspect-[16/10] w-full"
          >
            <LineChart data={data} margin={{ left: 0, right: 12, top: 8 }} accessibilityLayer>
              <CartesianGrid vertical={false} />
              <XAxis dataKey="year" tickLine={false} axisLine={false} tickMargin={8} />
              <YAxis tickLine={false} axisLine={false} width={44} tickFormatter={(v: number) => money.format(v)} />
              <ChartTooltip cursor={false} content={<ChartTooltipContent indicator="line" formatter={(v) => money.format(Number(v))} />} />
              <Line dataKey="subscribe" type="monotone" stroke="var(--color-subscribe)" strokeWidth={2} dot={{ r: 3 }} isAnimationActive={false} />
              <Line dataKey="once" type="monotone" stroke="var(--color-once)" strokeWidth={2} dot={{ r: 3 }} isAnimationActive={false} />
              <ChartLegend content={<ChartLegendContent />} />
            </LineChart>
          </ChartContainer>

          {renew > 0 && (
            <div className="mt-4 flex items-center justify-between gap-4 rounded-xl border p-3">
              <Label htmlFor={`${baseId}-keep`} className="text-sm leading-snug">
                Keep updates after year 1
                <span className="text-muted-foreground block text-xs font-normal">{money.format(renew)} per year, optional</span>
              </Label>
              <Switch id={`${baseId}-keep`} checked={keepUpdates} onCheckedChange={setKeepUpdates} />
            </div>
          )}
        </div>
      </div>
    </section>
  )
}

export { Pricing4, type Pricing4Props, type Pricing4Tier, type Pricing4Mode }
```
