shadcn + TanStack Data Table
The canonical admin-UI table, wired correctly.
Install
One-line install
npx attrition-sh pack install shadcn-data-table
AGENTS.md snippet (Claude Code / Cursor)
Skill `shadcn-data-table` is installed at .claude/skills/shadcn-data-table/SKILL.md. Invoke whenever the user needs a list-of-records UI with sorting, filtering, pagination, or selection. Prefer the TanStack + shadcn baseline over hand-rolled `<table>` markup; respect sticky-header and keyboard-sort requirements.
Raw Markdown
Machine-readable body for agent ingestion or copy/paste.
Telemetry
Not yet measuredRediscovery cost
Skipping this saves ~25,000 tokens / 45 min of research.
MethodologyHide
Rediscovery cost
Skipping this saves ~25,000 tokens / 45 min of research.
Measured 2026-04-16
Prompted fresh Claude Sonnet 4.6 with 'build a production data table in Next.js with sorting, server pagination, column visibility, row selection, and a11y'. Measured tokens until the output included TanStack useReactTable, manualPagination + pageCount, getRowId, aria-sort, URL-synced state, and empty/loading states. Averaged over 3 runs.
Summary
A content-complete data table built on TanStack Table v8 and shadcn/ui primitives. Ships with sortable columns, server-side pagination, column visibility toggles, row selection, per-column filters, sticky header, skeleton loading, a11y-correct header semantics, and an empty state. Replaces the 45-minute stub that every project writes twice.
Fit and expected payoff
When this pack earns its extra structure, when to skip it, and what it should improve.
Use when
Situations where this pack earns its extra structure.
- You are rendering 20–10,000 rows of structured data that users sort/filter/select.
- Your team has already standardised on shadcn/ui primitives.
- You need server-side pagination and filtering (URL-synced).
- You need row selection for bulk actions (archive, assign, delete).
Avoid when
Keeps the pack from becoming a default hammer.
- You need virtualised 100k+ row rendering — reach for ag-grid or TanStack Virtual directly.
- The data is better visualised as a board/kanban or a tree — don't force a table.
- You only have <10 rows — a simple `<ul>` or card grid is clearer.
- You need in-cell editing with complex validation — use a spreadsheet-style lib.
What it improves
Expected outcomes if implemented well.
- Sortable columns via keyboard; ARIA `aria-sort` reflects current direction.
- Server-pagination synced to URL so refresh preserves state.
- Column visibility menu; per-user preferences persisted to localStorage.
- Selection row spans checkbox, row-click (optional), and shift-range-select.
- Skeleton loading on first fetch; empty state with call-to-action on zero rows.
Minimal instructions
Smallest useful starting point.
## Minimal setup
```bash
pnpm add @tanstack/react-table
pnpm dlx shadcn@latest add table checkbox dropdown-menu button input
```
```tsx
// app/users/columns.tsx
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
export type User = { id: string; name: string; email: string; role: string };
export const columns: ColumnDef<User>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/>
),
enableSorting: false,
},
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
];
```
Pair with the `<DataTable>` wrapper below (see full instructions).Full instructions
Complete natural-language instruction set.
## Full reference: production data table
### 1. Why TanStack + shadcn
TanStack Table v8 is headless: it owns state (sorting, pagination, selection, filters) and exposes row models. shadcn gives you styled `<Table>`, `<Checkbox>`, `<DropdownMenu>`, `<Button>`. Together you get a real table in ~300 lines that you own and can read.
### 2. The `<DataTable>` wrapper
```tsx
"use client";
import * as React from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
SortingState,
VisibilityState,
RowSelectionState,
} from "@tanstack/react-table";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
export function DataTable<TData, TValue>({
columns, data, totalCount, pagination, setPagination,
}: {
columns: ColumnDef<TData, TValue>[];
data: TData[];
totalCount: number;
pagination: { pageIndex: number; pageSize: number };
setPagination: React.Dispatch<React.SetStateAction<{ pageIndex: number; pageSize: number }>>;
}) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const table = useReactTable({
data,
columns,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: { sorting, columnVisibility, rowSelection, pagination },
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
manualPagination: true,
manualSorting: true,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<div className="space-y-2">
<div className="rounded-md border">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => {
const sort = h.column.getIsSorted();
return (
<TableHead
key={h.id}
aria-sort={sort === "asc" ? "ascending" : sort === "desc" ? "descending" : "none"}
>
{h.isPlaceholder ? null : h.column.getCanSort() ? (
<button
className="flex items-center gap-1"
onClick={h.column.getToggleSortingHandler()}
>
{flexRender(h.column.columnDef.header, h.getContext())}
{sort === "asc" ? " ↑" : sort === "desc" ? " ↓" : ""}
</button>
) : (
flexRender(h.column.columnDef.header, h.getContext())
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {totalCount} selected.
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline"
onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Previous
</Button>
<Button size="sm" variant="outline"
onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</Button>
</div>
</div>
</div>
);
}
```
### 3. URL-synced pagination
Persist `pageIndex`, `pageSize`, and sort in the URL via Next's `useSearchParams` + `router.replace`. This makes refresh and share-links Just Work.
```tsx
const sp = useSearchParams();
const router = useRouter();
const pagination = {
pageIndex: Number(sp.get("page") ?? "0"),
pageSize: Number(sp.get("size") ?? "20"),
};
const setPagination = (update) => {
const next = typeof update === "function" ? update(pagination) : update;
const params = new URLSearchParams(sp);
params.set("page", String(next.pageIndex));
params.set("size", String(next.pageSize));
router.replace(`?${params.toString()}`);
};
```
### 4. Filtering
- **Per-column filter input** bound to `column.setFilterValue()`; debounce 200ms.
- **Global search** bound to `table.setGlobalFilter()`; for server-side, swap for a `q` query param.
- **Faceted filters** (multi-select by value): use `getFacetedUniqueValues()` + a popover with checkboxes. Linear's approach.
### 5. Column visibility
```tsx
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Columns</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{table.getAllColumns().filter((c) => c.getCanHide()).map((c) => (
<DropdownMenuCheckboxItem
key={c.id}
checked={c.getIsVisible()}
onCheckedChange={(v) => c.toggleVisibility(!!v)}
>
{c.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
```
Persist visibility to localStorage under a per-table key.
### 6. Loading and empty states
- **Loading**: render 5 skeleton rows matching column widths. Do not collapse the header.
- **Empty (no data yet)**: illustration + CTA ("Create your first user").
- **Empty (filtered out)**: "No results match your filters." + "Clear filters" button.
### 7. Accessibility
- `<table>` semantics (don't use `<div role="table">` without strong reason).
- `aria-sort` on sortable headers; toggle is a `<button>` with visible sort arrow.
- Checkbox header `aria-label="Select all"`; row checkbox `aria-label="Select row <identifier>"`.
- Row focus style visible with `:focus-visible`.
- Sticky header stacks above content with `z-10` and opaque background.
### 8. Performance
- At >500 rows, enable `@tanstack/react-virtual`. Wrap `TableBody` with a virtualiser.
- Memoise `columns` with `useMemo` — recreating the array on every render thrashes TanStack state.
- Avoid fetching all rows: prefer cursor or offset pagination at the API.
### 9. Testing
- Unit: column defs render expected strings for fixtures.
- Integration (Playwright): sort toggles, paginate, select row, bulk-action button enables.
- A11y: axe scan; NVDA sort announcement.
### 10. Common pitfalls
1. `columns` recreated inline in render — sorting/selection state resets every render.
2. `manualPagination` without supplying `pageCount` — the Next button never disables.
3. Row IDs derived from array index — selection breaks on sort. Use `getRowId: (r) => r.id`.
4. Sticky header with transparent background — rows bleed through on scroll.Evaluation checklist
These checks should pass before you consider the pattern production-ready.
- Header click sorts column; `aria-sort` updates; arrow indicator visible.
- Refresh preserves page, size, and sort via URL params.
- Column visibility menu hides/shows columns and persists across reloads.
- Select-all checkbox selects all rows on the current page (not all rows across pages).
- Empty state renders when result set is zero, with distinct copy for 'no data' vs 'filtered out'.
- Skeleton rows render on first fetch; header stays pinned.
- axe-core scan reports zero violations.
Common failure modes
Every check below traces back to a specific production failure. Read as: "I would think about X because in production Y can happen."
- Mid
User clicks sort header; table resets to unsorted on next render
- Trigger
- `columns` array re-created every render; TanStack sees a new ref and resets state
- Prevention
- Wrap the columns definition with `useMemo`
- Mid
User selects row 3, sorts a column, now a different row is selected
- Trigger
- Row IDs default to row index; sorting changes indices
- Prevention
- Provide a stable id via `getRowId: (row) => row.id`
- Mid
Next-page button never disables even on the last page (server pagination)
- Trigger
- `pageCount` missing; TanStack can't know when it's at the end
- Prevention
- Compute `Math.ceil(total / pageSize)` and pass as `pageCount`
- Junior
Sticky header looks transparent; rows scroll through it
- Trigger
- Header has no background and no z-index against the scroll container
- Prevention
- Apply `bg-background z-10` (or design token) on the sticky header cell
How this pack stacks up
Head-to-head notes vs alternative patterns.
| Alternative | Axis | Winner | Note |
|---|---|---|---|
| complexity | Tie | Orthogonal concerns — tables render structured records, palettes trigger commands. Use both. | |
| complexity | This pack | AG Grid wins on features (pivot, grouping, 100k rows). TanStack + shadcn wins on bundle size, readability, and ownership. |
How this pack connects
Injection surface, allow-list, and known issues
Injection surface
LowLast scanned
2026-04-16
Tool allow-list
No tool permissions granted.
Version history
v0.1.0
2026-04-16
Added
- Initial pack with DataTable wrapper, URL-synced pagination, column visibility, row selection
- A11y rules and sticky-header recipe
- Skeleton loading + dual empty-state pattern
Seed pack — first release.
Official docs and implementation references
shadcn/ui — Data Table guide
Official reference that pairs TanStack Table v8 with shadcn primitives. Starting point for the wrapper in this pack.
https://ui.shadcn.com/docs/components/data-tableTanStack Table v8 docs
Headless API reference — row models, state, manual pagination, getRowId.
https://tanstack.com/table/v8/docs/introductionTailwind UI — Table patterns
Design grammar for sticky headers, selection highlight, empty states that the shadcn styles mirror.
https://tailwindui.com/components/application-ui/lists/tablesWAI-ARIA — Sortable table pattern
Authoritative source for `aria-sort` semantics and keyboard-sort behaviour.
https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/