Pricing 3: full plan comparison
A feature-by-feature comparison table with a sticky plan header, collapsible groups and a highlighted column. On phones it becomes a plan switcher with one clean list.
// Ballmac UI: Pricing 3. https://ui.ballmac.com/blocks/pricing-3
"use client"
import * as React from "react"
import { Check, ChevronDown, Minus } from "lucide-react"
import { Badge } from "@/components/ballmac/badge"
import { buttonVariants } from "@/components/ballmac/button"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { cn } from "@/lib/utils"
type Pricing3Plan = {
/** Stable key used by the feature rows. */
key: string
/** Plan name. */
name: string
/** Price text, e.g. "$29". */
price: string
/** What the price covers, e.g. "per seat / month". */
period?: string
/** Button. */
cta: { label: string; href: string }
/** Emphasise this plan. */
featured?: boolean
}
type Pricing3Row = {
/** Feature name. */
label: string
/** Extra detail shown under the name. */
hint?: string
/** Value for each plan key: true (included), false (not included) or text such as "10 GB". */
values: Record<string, boolean | string>
}
type Pricing3Group = {
/** Group heading, e.g. "Collaboration". */
title: string
rows: Pricing3Row[]
}
type Pricing3Props = Omit<React.ComponentProps<"section">, "title"> & {
/** Section heading. */
title?: string
/** One sentence under the heading. */
description?: string
/** The columns to compare. */
plans?: Pricing3Plan[]
/** Feature groups, each a set of rows. */
groups?: Pricing3Group[]
/** Distance in pixels the sticky header keeps from the top of the page, e.g. the height of your site header. */
stickyOffset?: number
}
const defaultPlans: Pricing3Plan[] = [
{ key: "starter", name: "Starter", price: "$12", period: "per month", cta: { label: "Start free", href: "#" } },
{ key: "pro", name: "Pro", price: "$32", period: "per seat / month", cta: { label: "Start trial", href: "#" }, featured: true },
{ key: "scale", name: "Scale", price: "$79", period: "per seat / month", cta: { label: "Talk to sales", href: "#" } },
]
const defaultGroups: Pricing3Group[] = [
{
title: "Core",
rows: [
{ label: "Projects", values: { starter: "3", pro: "Unlimited", scale: "Unlimited" } },
{ label: "Storage", values: { starter: "10 GB", pro: "1 TB", scale: "10 TB" } },
{ label: "Version history", hint: "Restore any earlier version", values: { starter: "7 days", pro: "90 days", scale: "Unlimited" } },
{ label: "Custom domain", values: { starter: false, pro: true, scale: true } },
],
},
{
title: "Collaboration",
rows: [
{ label: "Guests", values: { starter: "5", pro: "50", scale: "Unlimited" } },
{ label: "Comments and mentions", values: { starter: true, pro: true, scale: true } },
{ label: "Team roles", values: { starter: false, pro: true, scale: true } },
{ label: "Shared workspaces", values: { starter: false, pro: true, scale: true } },
],
},
{
title: "Security and support",
rows: [
{ label: "Single sign-on (SAML)", values: { starter: false, pro: true, scale: true } },
{ label: "Audit log", values: { starter: false, pro: false, scale: true } },
{ label: "SCIM provisioning", values: { starter: false, pro: false, scale: true } },
{ label: "Support", values: { starter: "Community", pro: "Priority email", scale: "Dedicated manager" } },
{ label: "Uptime SLA", values: { starter: false, pro: false, scale: "99.99%" } },
],
},
]
function Value({ value, label }: { value: boolean | string; label: string }) {
if (typeof value === "string") return <span className="text-sm font-medium">{value}</span>
return value ? (
<Check className="text-chart-2 mx-auto size-4.5" aria-label={`${label}: included`} role="img" />
) : (
<Minus className="text-muted-foreground mx-auto size-4" aria-label={`${label}: not included`} role="img" />
)
}
function Pricing3({
title = "Compare every plan.",
description = "All the details, side by side.",
plans = defaultPlans,
groups = defaultGroups,
stickyOffset = 0,
className,
...props
}: Pricing3Props) {
const [closed, setClosed] = React.useState<Set<string>>(() => new Set())
const [mobilePlan, setMobilePlan] = React.useState(plans.find((p) => p.featured)?.key ?? plans[0]?.key ?? "")
const selected = plans.find((p) => p.key === mobilePlan) ?? plans[0]
const baseId = React.useId()
function toggle(group: string) {
setClosed((prev) => {
const next = new Set(prev)
if (next.has(group)) next.delete(group)
else next.add(group)
return next
})
}
return (
<section data-slot="pricing-3" className={cn("mx-auto max-w-5xl 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>
{/* Phones: one plan at a time. */}
{selected && (
<div className="mt-10 md:hidden">
<SegmentedControl aria-label="Plan to view" value={selected.key} onValueChange={setMobilePlan} fullWidth>
{plans.map((p) => (
<SegmentedControlItem key={p.key} value={p.key}>{p.name}</SegmentedControlItem>
))}
</SegmentedControl>
<div className="bg-card mt-5 rounded-2xl border p-5">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-semibold tracking-[-0.04em]">{selected.price}</span>
{selected.period && <span className="text-muted-foreground text-sm">{selected.period}</span>}
</div>
<a href={selected.cta.href} className={buttonVariants({ variant: selected.featured ? "default" : "outline", shape: "pill", className: "mt-4 w-full" })}>
{selected.cta.label}
</a>
</div>
{groups.map((g) => (
<div key={g.title} className="mt-6">
<h3 className="text-muted-foreground px-1 pb-2 text-xs font-semibold tracking-wide uppercase">{g.title}</h3>
<ul className="bg-card divide-y rounded-2xl border">
{g.rows.map((r) => (
<li key={r.label} className="flex items-center justify-between gap-4 px-4 py-3">
<span className="text-sm">{r.label}</span>
<span className="shrink-0 text-end"><Value value={r.values[selected.key] ?? false} label={r.label} /></span>
</li>
))}
</ul>
</div>
))}
</div>
)}
{/* Tablets and up: the full table with a sticky header. */}
<div className="mt-14 hidden md:block">
<table className="w-full border-separate border-spacing-0 text-start">
<caption className="sr-only">Plan comparison</caption>
<thead>
<tr>
<th scope="col" className="bg-background/90 sticky z-10 w-[32%] border-b p-0 backdrop-blur-xl" style={{ top: stickyOffset }}>
<span className="sr-only">Feature</span>
</th>
{plans.map((p) => (
<th
key={p.key}
scope="col"
style={{ top: stickyOffset }}
className={cn("bg-background/90 sticky z-10 border-b px-4 pt-2 pb-5 align-bottom backdrop-blur-xl", p.featured && "bg-muted/60")}
>
<span className="flex items-center gap-2 text-base font-semibold">
{p.name}
{p.featured && <Badge>Popular</Badge>}
</span>
<span className="mt-2 flex items-baseline gap-1.5 font-normal">
<span className="text-3xl font-semibold tracking-[-0.04em]">{p.price}</span>
{p.period && <span className="text-muted-foreground text-xs">{p.period}</span>}
</span>
<a href={p.cta.href} className={buttonVariants({ variant: p.featured ? "default" : "outline", size: "sm", shape: "pill", className: "mt-4 w-full" })}>
{p.cta.label}
</a>
</th>
))}
</tr>
</thead>
{groups.map((g, gi) => {
const open = !closed.has(g.title)
const panelId = `${baseId}-g${gi}`
return (
<tbody key={g.title}>
<tr>
<th scope="colgroup" colSpan={plans.length + 1} className="p-0 pt-8 text-start">
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => toggle(g.title)}
className="focus-visible:ring-ring/50 group/head flex w-full items-center justify-between rounded-lg px-1 py-2 text-sm font-semibold outline-none focus-visible:ring-[3px]"
>
{g.title}
<ChevronDown className={cn("text-muted-foreground size-4 transition-transform duration-200 motion-reduce:transition-none", !open && "-rotate-90")} aria-hidden="true" />
</button>
</th>
</tr>
{open &&
g.rows.map((r, ri) => (
<tr key={r.label} id={ri === 0 ? panelId : undefined} className="group/row">
<th scope="row" className="border-t px-1 py-3.5 text-start text-sm font-normal">
{r.label}
{r.hint && <span className="text-muted-foreground block text-xs">{r.hint}</span>}
</th>
{plans.map((p) => (
<td key={p.key} className={cn("border-t px-4 py-3.5 text-center", p.featured && "bg-muted/40")}>
<Value value={r.values[p.key] ?? false} label={r.label} />
</td>
))}
</tr>
))}
</tbody>
)
})}
</table>
</div>
</section>
)
}
export { Pricing3, type Pricing3Props, type Pricing3Plan, type Pricing3Group, type Pricing3Row }Installation
$ pnpm dlx shadcn@latest add @ballmac/pricing-3Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/badge @ballmac/button @ballmac/segmented-controlCopy the source into your project.
components/ballmac/blocks/pricing-3/pricing-3.tsx// Ballmac UI: Pricing 3. https://ui.ballmac.com/blocks/pricing-3 "use client" import * as React from "react" import { Check, ChevronDown, Minus } from "lucide-react" import { Badge } from "@/components/ballmac/badge" import { buttonVariants } from "@/components/ballmac/button" import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control" import { cn } from "@/lib/utils" type Pricing3Plan = { /** Stable key used by the feature rows. */ key: string /** Plan name. */ name: string /** Price text, e.g. "$29". */ price: string /** What the price covers, e.g. "per seat / month". */ period?: string /** Button. */ cta: { label: string; href: string } /** Emphasise this plan. */ featured?: boolean } type Pricing3Row = { /** Feature name. */ label: string /** Extra detail shown under the name. */ hint?: string /** Value for each plan key: true (included), false (not included) or text such as "10 GB". */ values: Record<string, boolean | string> } type Pricing3Group = { /** Group heading, e.g. "Collaboration". */ title: string rows: Pricing3Row[] } type Pricing3Props = Omit<React.ComponentProps<"section">, "title"> & { /** Section heading. */ title?: string /** One sentence under the heading. */ description?: string /** The columns to compare. */ plans?: Pricing3Plan[] /** Feature groups, each a set of rows. */ groups?: Pricing3Group[] /** Distance in pixels the sticky header keeps from the top of the page, e.g. the height of your site header. */ stickyOffset?: number } const defaultPlans: Pricing3Plan[] = [ { key: "starter", name: "Starter", price: "$12", period: "per month", cta: { label: "Start free", href: "#" } }, { key: "pro", name: "Pro", price: "$32", period: "per seat / month", cta: { label: "Start trial", href: "#" }, featured: true }, { key: "scale", name: "Scale", price: "$79", period: "per seat / month", cta: { label: "Talk to sales", href: "#" } }, ] const defaultGroups: Pricing3Group[] = [ { title: "Core", rows: [ { label: "Projects", values: { starter: "3", pro: "Unlimited", scale: "Unlimited" } }, { label: "Storage", values: { starter: "10 GB", pro: "1 TB", scale: "10 TB" } }, { label: "Version history", hint: "Restore any earlier version", values: { starter: "7 days", pro: "90 days", scale: "Unlimited" } }, { label: "Custom domain", values: { starter: false, pro: true, scale: true } }, ], }, { title: "Collaboration", rows: [ { label: "Guests", values: { starter: "5", pro: "50", scale: "Unlimited" } }, { label: "Comments and mentions", values: { starter: true, pro: true, scale: true } }, { label: "Team roles", values: { starter: false, pro: true, scale: true } }, { label: "Shared workspaces", values: { starter: false, pro: true, scale: true } }, ], }, { title: "Security and support", rows: [ { label: "Single sign-on (SAML)", values: { starter: false, pro: true, scale: true } }, { label: "Audit log", values: { starter: false, pro: false, scale: true } }, { label: "SCIM provisioning", values: { starter: false, pro: false, scale: true } }, { label: "Support", values: { starter: "Community", pro: "Priority email", scale: "Dedicated manager" } }, { label: "Uptime SLA", values: { starter: false, pro: false, scale: "99.99%" } }, ], }, ] function Value({ value, label }: { value: boolean | string; label: string }) { if (typeof value === "string") return <span className="text-sm font-medium">{value}</span> return value ? ( <Check className="text-chart-2 mx-auto size-4.5" aria-label={`${label}: included`} role="img" /> ) : ( <Minus className="text-muted-foreground mx-auto size-4" aria-label={`${label}: not included`} role="img" /> ) } function Pricing3({ title = "Compare every plan.", description = "All the details, side by side.", plans = defaultPlans, groups = defaultGroups, stickyOffset = 0, className, ...props }: Pricing3Props) { const [closed, setClosed] = React.useState<Set<string>>(() => new Set()) const [mobilePlan, setMobilePlan] = React.useState(plans.find((p) => p.featured)?.key ?? plans[0]?.key ?? "") const selected = plans.find((p) => p.key === mobilePlan) ?? plans[0] const baseId = React.useId() function toggle(group: string) { setClosed((prev) => { const next = new Set(prev) if (next.has(group)) next.delete(group) else next.add(group) return next }) } return ( <section data-slot="pricing-3" className={cn("mx-auto max-w-5xl 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> {/* Phones: one plan at a time. */} {selected && ( <div className="mt-10 md:hidden"> <SegmentedControl aria-label="Plan to view" value={selected.key} onValueChange={setMobilePlan} fullWidth> {plans.map((p) => ( <SegmentedControlItem key={p.key} value={p.key}>{p.name}</SegmentedControlItem> ))} </SegmentedControl> <div className="bg-card mt-5 rounded-2xl border p-5"> <div className="flex items-baseline gap-2"> <span className="text-4xl font-semibold tracking-[-0.04em]">{selected.price}</span> {selected.period && <span className="text-muted-foreground text-sm">{selected.period}</span>} </div> <a href={selected.cta.href} className={buttonVariants({ variant: selected.featured ? "default" : "outline", shape: "pill", className: "mt-4 w-full" })}> {selected.cta.label} </a> </div> {groups.map((g) => ( <div key={g.title} className="mt-6"> <h3 className="text-muted-foreground px-1 pb-2 text-xs font-semibold tracking-wide uppercase">{g.title}</h3> <ul className="bg-card divide-y rounded-2xl border"> {g.rows.map((r) => ( <li key={r.label} className="flex items-center justify-between gap-4 px-4 py-3"> <span className="text-sm">{r.label}</span> <span className="shrink-0 text-end"><Value value={r.values[selected.key] ?? false} label={r.label} /></span> </li> ))} </ul> </div> ))} </div> )} {/* Tablets and up: the full table with a sticky header. */} <div className="mt-14 hidden md:block"> <table className="w-full border-separate border-spacing-0 text-start"> <caption className="sr-only">Plan comparison</caption> <thead> <tr> <th scope="col" className="bg-background/90 sticky z-10 w-[32%] border-b p-0 backdrop-blur-xl" style={{ top: stickyOffset }}> <span className="sr-only">Feature</span> </th> {plans.map((p) => ( <th key={p.key} scope="col" style={{ top: stickyOffset }} className={cn("bg-background/90 sticky z-10 border-b px-4 pt-2 pb-5 align-bottom backdrop-blur-xl", p.featured && "bg-muted/60")} > <span className="flex items-center gap-2 text-base font-semibold"> {p.name} {p.featured && <Badge>Popular</Badge>} </span> <span className="mt-2 flex items-baseline gap-1.5 font-normal"> <span className="text-3xl font-semibold tracking-[-0.04em]">{p.price}</span> {p.period && <span className="text-muted-foreground text-xs">{p.period}</span>} </span> <a href={p.cta.href} className={buttonVariants({ variant: p.featured ? "default" : "outline", size: "sm", shape: "pill", className: "mt-4 w-full" })}> {p.cta.label} </a> </th> ))} </tr> </thead> {groups.map((g, gi) => { const open = !closed.has(g.title) const panelId = `${baseId}-g${gi}` return ( <tbody key={g.title}> <tr> <th scope="colgroup" colSpan={plans.length + 1} className="p-0 pt-8 text-start"> <button type="button" aria-expanded={open} aria-controls={panelId} onClick={() => toggle(g.title)} className="focus-visible:ring-ring/50 group/head flex w-full items-center justify-between rounded-lg px-1 py-2 text-sm font-semibold outline-none focus-visible:ring-[3px]" > {g.title} <ChevronDown className={cn("text-muted-foreground size-4 transition-transform duration-200 motion-reduce:transition-none", !open && "-rotate-90")} aria-hidden="true" /> </button> </th> </tr> {open && g.rows.map((r, ri) => ( <tr key={r.label} id={ri === 0 ? panelId : undefined} className="group/row"> <th scope="row" className="border-t px-1 py-3.5 text-start text-sm font-normal"> {r.label} {r.hint && <span className="text-muted-foreground block text-xs">{r.hint}</span>} </th> {plans.map((p) => ( <td key={p.key} className={cn("border-t px-4 py-3.5 text-center", p.featured && "bg-muted/40")}> <Value value={r.values[p.key] ?? false} label={r.label} /> </td> ))} </tr> ))} </tbody> ) })} </table> </div> </section> ) } export { Pricing3, type Pricing3Props, type Pricing3Plan, type Pricing3Group, type Pricing3Row }Update the import paths to match your project setup.
Usage
import { Pricing3 } from "@/components/ballmac/blocks/pricing-3/pricing-3"
export default function Pricing3Demo() {
return <Pricing3 />
}Dependencies
- npm
- lucide-react
Use with AI
The detailed pricing table for buyers who compare. Pass plans=[{ key, name, price, period?, cta, featured? }] and groups=[{ title, rows: [{ label, hint?, values: { [planKey]: boolean | string } }] }]. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Pricing 3: full plan comparison (@ballmac/pricing-3) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Below pricing cards, for buyers who want every detail
- Products with many differentiating features
Not for
- Two simple plans (pricing-1 is enough)
Registry JSON: https://ui.ballmac.com/r/pricing-3.json
Free to use in personal and commercial projects.
More pricing blocks
Pricing 1: two tiers
Two pricing cards side by side with included and excluded features, a highlighted plan with a border beam and badge, and small print.
Pricing 2: three tiers with billing toggle
Three plan cards with a monthly/yearly switch. Prices roll to the new value, a savings badge and billed-total line update, and the middle plan is lifted with a border beam.
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.
Pricing Pro 1: rolling prices with billing switch
Three plans with a Monthly/Yearly switch where prices roll to the new number, a computed saving badge and a glowing featured plan.