Comparison Table
Pricing plans as columns in an ordinary bordered table — feature rows compare booleans and values across every plan at once, with the recommended plan's column tinted to stand out.
npx shadcn add https://shadcn-table-library.vercel.app/r/comparison-table.jsonBasic $9/month | Pro $29/month | Enterprise $99/month | |
|---|---|---|---|
| Team members | 5 | 20 | Unlimited |
| Storage | 10 GB | 100 GB | 1 TB |
| API access | |||
| Priority support | |||
| Custom domain | |||
| Single sign-on |
How it works
1.Columns are generated per plan, not authored
Same dynamic-column technique as the Pivot Table: plans.map(...) builds one ColumnDef per pricing plan inside a useMemo. Add a fourth plan to the data and a fourth column — header, price, CTA button, and every feature cell beneath it — appears with zero changes to the table component itself.
const columns = useMemo<ColumnDef<Feature>[]>(() => [{ id: 'feature', header: '', accessorKey: 'label', /* ... */ },...plans.map((plan) => ({id: plan.id,header: plan.name,accessorFn: (feature) => feature.values[plan.id],cell: ({ getValue }) => <div className="text-center">{renderValue(getValue())}</div>,})),],[plans],)
2.It's an ordinary bordered table — only the header cell is richer
Same overflow-hidden rounded-md border wrapper and default TableRow/TableCell borders as every other table in this library. The only thing that makes this read as a pricing table is that a plan's header cell renders a small card of its own — name, price, and a CTA Button — instead of a plain text label.
<div className="overflow-hidden rounded-md border"><Table><TableHeader><TableRow><TableHead />{plans.map((plan) => (<TableHead key={plan.id} className="py-4 align-bottom text-center">{/* plan name, price, and CTA Button */}</TableHead>))}</TableRow></TableHeader>{/* ... */}</Table></div>
3.The highlighted plan is one boolean, applied per cell
plan.highlighted just adds a bg-primary/5 tint to that column's header cell and every one of its body cells — no special borders, no rounding, nothing column-specific beyond that one background class. The tint alone is enough to draw the eye down the "Pro" column without breaking the table's normal grid.
<TableCellkey={cell.id}className={cn(plan?.highlighted && 'bg-primary/5')}>
4.Feature rows render differently by value type, from one function
renderValue() checks typeof value === 'boolean' to decide between a Check/X icon or the raw string. The column definition itself doesn't know the difference — accessorFn just reads feature.values[plan.id] — so "Team members" can render "Unlimited" and "Single sign-on" can render a checkmark, both through the same cell renderer.
function renderValue(value: FeatureValue) {if (typeof value === 'boolean') {return value ? <Check className="text-emerald-600" /> : <X className="text-muted-foreground/40" />}return value}
5.The header cell's height comes from its own content, not the table
A plan's header cell holds three stacked pieces — name, price, and a full-width Button — instead of one line of text, while the leading "" header cell next to it stays empty. align-bottom py-4 on every TableHead keeps that taller content flush with the row instead of vertically centering oddly against the empty cell beside it.
<TableHeadkey={plan.id}className={cn('py-4 align-bottom text-center', plan.highlighted && 'bg-primary/5')}><div className="space-y-1"><p className="text-sm font-semibold">{plan.name}</p><p className="text-2xl font-bold">{plan.price}<span className="text-sm font-normal text-muted-foreground">{plan.period}</span></p><Button className="mt-2 w-full">{plan.cta}</Button></div></TableHead>