ShadTable
DiscordGitHub

ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

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.json

Basic

$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.

src/components/comparison/data-table.tsx
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.

src/components/comparison/data-table.tsx
<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.

src/components/comparison/data-table.tsx
<TableCell
key={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.

src/components/comparison/data-table.tsx
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.

src/components/comparison/data-table.tsx
<TableHead
key={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>