ShadTable
DiscordGitHub

ShadTable

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

Pivot Table

Dashboard-style analytics: pick which dimension becomes rows, which becomes columns, and how to aggregate (sum, average, or count) — the table and its totals recompute from the same flat sales data every time.

npx shadcn add https://shadcn-table-library.vercel.app/r/pivot-table.json
Rows
Columns
Aggregate
RegionQ1Q2Q3Q4Total
East$55,000$59,400$52,250$67,100$233,750
North$42,000$45,360$39,900$51,240$178,500
South$31,000$33,480$29,450$37,820$131,750
West$38,000$41,040$36,100$46,360$161,500
Total$166,000$179,280$157,700$202,520$705,500

How it works

1.The reshape happens before the table ever sees it

pivotData() takes flat sales records plus a rowKey, colKey, and aggFn, and returns plain { rowValue, cells, total } objects — no TanStack Table types involved. The table itself has no idea what a "pivot" is; by the time useReactTable sees the data, it's already a flat list of rows like any other table in this library.

src/components/pivot/pivot.ts
export function pivotData(data, rowKey, colKey, aggFn) {
const rowValues = Array.from(new Set(data.map((d) => String(d[rowKey])))).sort()
const colValues = Array.from(new Set(data.map((d) => String(d[colKey])))).sort()
const rows = rowValues.map((rowValue) => {
const rowRecords = data.filter((d) => String(d[rowKey]) === rowValue)
const cells = {}
for (const colValue of colValues) {
cells[colValue] = aggregate(rowRecords.filter((d) => String(d[colKey]) === colValue), aggFn)
}
return { rowValue, cells, total: aggregate(rowRecords, aggFn) }
})
// ...
}

2.Columns are generated from the data, not authored

colValues — the pivoted axis's unique values — drives a useMemo'd ColumnDef[]. Switch "Columns" from Quarter to Channel and the table goes from 4 pivoted columns to 2, entirely from what's actually in the data that render, with no static column list to keep in sync.

src/components/pivot/data-table.tsx
const columns = useMemo<ColumnDef<PivotRow>[]>(
() => [
{ id: 'rowValue', header: rowLabel, accessorKey: 'rowValue', /* ... */ },
...colValues.map((colValue) => ({
id: colValue,
header: colValue,
accessorFn: (row) => row.cells[colValue],
cell: ({ getValue }) => formatValue(getValue(), aggFn),
})),
{ id: 'total', header: 'Total', accessorKey: 'total', /* ... */ },
],
[colValues, rowLabel, aggFn],
)

3.Row and column pickers can't select the same dimension twice

Each Select's option list filters out whatever the other picker currently holds — the Rows dropdown never offers whatever Columns is already set to, and vice versa. A meaningless Region-by-Region pivot is structurally impossible to select, rather than something validated (or silently broken) after the fact.

src/components/pivot/data-table.tsx
<Select value={rowDim} onValueChange={(v) => setRowDim(v as keyof SalesRecord)}>
<SelectContent>
{DIMENSIONS.filter((d) => d.value !== colDim).map((d) => (
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
))}
</SelectContent>
</Select>

4.One aggregate function drives every number on the page

aggregate() is the single function called for every cell, every column subtotal, and the grand total. Sum, average, and count all reuse it, so switching "Aggregate" from Sum to Average recomputes the whole table — including the totals row — with no separate subtotal formula that could drift out of sync with the body.

src/components/pivot/pivot.ts
export function aggregate(records: SalesRecord[], aggFn: AggregationType): number {
if (aggFn === 'count') return records.length
if (records.length === 0) return 0
const total = records.reduce((sum, r) => sum + r.amount, 0)
return aggFn === 'avg' ? total / records.length : total
}

5.The grand-total row bypasses TanStack Table entirely

columnTotals and grandTotal are rendered directly into a <TableFooter>, not through table.getRowModel() — it's a single row that doesn't sort, filter, or paginate like the others, so keeping it outside the table's own row model means it never has to fight those features if this example grows to include them later.

src/components/pivot/data-table.tsx
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
{colValues.map((colValue) => (
<TableCell key={colValue}>{formatValue(columnTotals[colValue], aggFn)}</TableCell>
))}
<TableCell>{formatValue(grandTotal, aggFn)}</TableCell>
</TableRow>
</TableFooter>