Pricing compare
A feature by plan comparison in real table markup, with grouped rows.
npx shadcn@latest add https://ui.goodtabs.co/r/pricing-compare.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/pricing-compare
| Feature | Free | Pro | Team |
|---|---|---|---|
| Core | |||
| Projects | 3 | Unlimited | Unlimited |
| History | 7 days | 1 year | Forever |
| Private repos | Not included | Included | Included |
One group, a tinted column for the middle plan.
Grouped rows
| Feature | Hobby | Team | Studio |
|---|---|---|---|
| Library | |||
| Components per day | 5 | Unlimited | Unlimited |
| Private collections | Not included | Included | Included |
| Skill packs | Not included | Included | Included |
| Collaboration | |||
| Seats | 1 | Per seat | Per seat |
| Shared workspaces | Not included | Included | Included |
| Client workspaces | Not included | Not included | Included |
| Support | |||
| Community forum | Included | Included | Included |
| Priority replies | Not included | 1 day | 4 hours |
| Invoice billing | Not included | Not included | Included |
Three groups of rows with checks, dashes and short values.
Usage
import { PricingCompare } from "@/components/pricing-compare"
<PricingCompare caption="Compare plans" plans={["Free", "Pro"]} groups={[{ name: "Core", rows: [{ feature: "Projects", values: ["3", "Unlimited"] }] }]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| plans | string[] | Column names. | |
| groups | Group[] | name plus rows of feature and one value per plan: true, false or a string. | |
| caption | string | Visible caption that also names the table. | |
| emphasised | number | Index of the plan column to tint. |
Source
pricing-compare/pricing-compare.tsx
import * as React from "react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/** A cell value: true is included, false is not, a string is shown as text. */
type Cell = boolean | string;
type Group = {
/** Group label, for example "Collaboration". */
name: string;
/** Rows in the group. `values` has one entry per plan, in plan order. */
rows: { feature: string; values: Cell[] }[];
};
type PricingCompareProps = Omit<React.ComponentProps<"table">, "title"> & {
/** Plan names, one per column. */
plans: string[];
/** Index of the plan to emphasise with a tinted column. */
emphasised?: number;
/** Feature groups, each a labelled block of rows. */
groups: Group[];
/** Visible caption above the table. Also names the table. */
caption: string;
};
function CellValue({ value }: { value: Cell }) {
if (value === true) {
return (
<>
<CheckIcon className="mx-auto size-4 text-foreground" aria-hidden="true" />
<span className="sr-only">Included</span>
</>
);
}
if (value === false) {
return (
<>
<MinusIcon className="mx-auto size-4 text-muted-foreground/60" aria-hidden="true" />
<span className="sr-only">Not included</span>
</>
);
}
return <span className="font-mono text-xs text-foreground/85">{value}</span>;
}
/**
* A feature by plan comparison. Real table markup: a caption, column headers with scope="col",
* one tbody per group and row headers with scope="row". Scrolls sideways on narrow screens.
* Server component.
*/
export function PricingCompare({ plans, emphasised, groups, caption, className, ...props }: PricingCompareProps) {
const tint = (i: number) => (i === emphasised ? "bg-muted/50" : undefined);
return (
<div data-slot="pricing-compare" className="relative w-full overflow-x-auto rounded-lg border bg-card">
<table className={cn("w-full min-w-[32rem] border-collapse text-sm", className)} {...props}>
<caption className="px-4 py-3 text-left text-sm font-medium text-foreground">{caption}</caption>
<thead>
<tr className="border-y">
<th scope="col" className="w-2/5 px-4 py-2.5 text-left font-mono text-xs font-normal text-muted-foreground">
Feature
</th>
{plans.map((p, i) => (
<th
key={i}
scope="col"
className={cn("px-4 py-2.5 text-center text-sm font-medium text-foreground", tint(i))}
>
{p}
</th>
))}
</tr>
</thead>
{groups.map((g, groupIndex) => (
<tbody key={groupIndex} className="border-b last:border-b-0">
<tr>
<th
scope="colgroup"
colSpan={plans.length + 1}
className="px-4 pt-4 pb-1.5 text-left font-mono text-xs font-normal tracking-wide text-muted-foreground uppercase"
>
{g.name}
</th>
</tr>
{g.rows.map((r, rowIndex) => (
<tr key={rowIndex} className="border-t border-border/60 first:border-t-0">
<th scope="row" className="px-4 py-2.5 text-left font-normal text-foreground/85">
{r.feature}
</th>
{r.values.map((v, i) => (
<td key={i} className={cn("px-4 py-2.5 text-center", tint(i))}>
<CellValue value={v} />
</td>
))}
</tr>
))}
</tbody>
))}
</table>
</div>
);
}
export type { Group, Cell };
For AI agents
Hand your agent this page, the markdown at /components/pricing-compare/prompt.md, or the Copy AI prompt button above.
When to use
- Below pricing cards when buyers need the full list
- When plans differ across many features
- Any feature by plan matrix
When not to use
- For three bullets per plan, cards are enough
- For data tables with sorting, use a data table
- For a single plan
Agent notes
- Real table: caption, th scope col, tbody per group, th scope row; values are true, false or text
- True and false render an icon plus sr-only text, so screen readers hear Included
- Values array must match plans in length and order
- Scrolls sideways under 32rem, keep plan names short