---
slug: "shadcn-data-table"
name: "shadcn + TanStack Data Table"
packType: "ui"
canonicalPattern: "n/a"
version: "0.1.0"
trust: "Community"
publisher: "Agent Workspace"
updatedAt: "2026-04-16"
---

# shadcn + TanStack Data Table

> The canonical admin-UI table, wired correctly.

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

## Install

```sh
npx attrition-sh pack install shadcn-data-table
```

### Claude Code / AGENTS.md snippet

```md
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.
```

## Contract

_No execution contract defined for this pack type._

## Layers

_No three-layer split defined for this pack type._

## Use When

- 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

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

## Key Outcomes

- 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

## 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

## 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

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

## Failure Modes

- **[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`
- **[JR] 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

## Transfer Matrix

_No measured cross-model transfer data._

## Telemetry

_No telemetry recorded._

## Security Review

- Injection surface: **low**
- Tool allow-list: _none specified_
- Last scanned: 2026-04-16

### Known issues
_None reported._

## Compares With

| Compared to | Axis | Winner | Note |
| --- | --- | --- | --- |
| `linear-command-palette` | complexity | tie | Orthogonal concerns — tables render structured records, palettes trigger commands. Use both. |
| `ag-grid-enterprise` | complexity | self | AG Grid wins on features (pivot, grouping, 100k rows). TanStack + shadcn wins on bundle size, readability, and ownership. |

## Related Packs

- `linear-command-palette`
- `pattern-decision-tree`

## Changelog

### 0.1.0 — 2026-04-16
_Seed pack — first release._

**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

## Sources

- [shadcn/ui — Data Table guide](https://ui.shadcn.com/docs/components/data-table) — Official reference that pairs TanStack Table v8 with shadcn primitives. Starting point for the wrapper in this pack.
- [TanStack Table v8 docs](https://tanstack.com/table/v8/docs/introduction) — Headless API reference — row models, state, manual pagination, getRowId.
- [Tailwind UI — Table patterns](https://tailwindui.com/components/application-ui/lists/tables) — Design grammar for sticky headers, selection highlight, empty states that the shadcn styles mirror.
- [WAI-ARIA — Sortable table pattern](https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/) — Authoritative source for `aria-sort` semantics and keyboard-sort behaviour.

## Examples

- [shadcn data table live demo](https://ui.shadcn.com/examples/tasks) (external)
- [TanStack Table sorting example](https://tanstack.com/table/v8/docs/framework/react/examples/sorting) (external)
