Data Table

A sortable, filterable, paginated table built on TanStack Table.

Ada Lovelaceada@analytical.devPaid$316.00
Grace Hoppergrace@cobol.orgPending$242.00
Alan Turingalan@bletchley.ukPaid$837.00
Katherine Johnsonkatherine@nasa.govFailed$125.00
Margaret Hamiltonmargaret@apollo.spacePaid$721.00

0 of 12 row(s) selected.

Page 1 of 3
'use client'

import {
  columnFilteringFeature,
  columnVisibilityFeature,
  createColumnHelper,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_basic,
  tableFeatures,
  useTable,
} from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'

type Payment = {
  id: string
  customer: string
  email: string
  status: 'Paid' | 'Pending' | 'Failed'
  amount: number
}

const data: Payment[] = [
  { id: 'INV-1001', customer: 'Ada Lovelace', email: 'ada@analytical.dev', status: 'Paid', amount: 316 },
  { id: 'INV-1002', customer: 'Grace Hopper', email: 'grace@cobol.org', status: 'Pending', amount: 242 },
  { id: 'INV-1003', customer: 'Alan Turing', email: 'alan@bletchley.uk', status: 'Paid', amount: 837 },
  { id: 'INV-1004', customer: 'Katherine Johnson', email: 'katherine@nasa.gov', status: 'Failed', amount: 125 },
  { id: 'INV-1005', customer: 'Margaret Hamilton', email: 'margaret@apollo.space', status: 'Paid', amount: 721 },
  { id: 'INV-1006', customer: 'Linus Torvalds', email: 'linus@kernel.org', status: 'Pending', amount: 459 },
  { id: 'INV-1007', customer: 'Barbara Liskov', email: 'barbara@mit.edu', status: 'Paid', amount: 598 },
  { id: 'INV-1008', customer: 'Donald Knuth', email: 'don@stanford.edu', status: 'Failed', amount: 64 },
  { id: 'INV-1009', customer: 'Radia Perlman', email: 'radia@spanningtree.net', status: 'Paid', amount: 903 },
  { id: 'INV-1010', customer: 'Tim Berners-Lee', email: 'tim@w3.org', status: 'Pending', amount: 377 },
  { id: 'INV-1011', customer: 'Hedy Lamarr', email: 'hedy@spread.spectrum', status: 'Paid', amount: 281 },
  { id: 'INV-1012', customer: 'Dennis Ritchie', email: 'dennis@bell-labs.com', status: 'Paid', amount: 512 },
]

// Register only the table features this table uses; each row-model slot follows the feature it needs.
const features = tableFeatures({
  rowSortingFeature,
  columnFilteringFeature,
  columnVisibilityFeature,
  rowSelectionFeature,
  rowPaginationFeature,
  sortedRowModel: createSortedRowModel(),
  filteredRowModel: createFilteredRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
  filterFns: { includesString: filterFn_includesString },
})

const columnHelper = createColumnHelper<typeof features, Payment>()
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })

const columns = columnHelper.columns([
  columnHelper.display({
    id: 'select',
    enableSorting: false,
    enableHiding: false,
    header: ({ table }) => (
      <Checkbox
        aria-label="Select all rows on this page"
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
        onCheckedChange={value => table.toggleAllPageRowsSelected(value)}
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        aria-label={`Select ${row.original.customer}`}
        checked={row.getIsSelected()}
        onCheckedChange={value => row.toggleSelected(value)}
      />
    ),
  }),
  columnHelper.accessor('customer', { header: 'Customer', cell: info => <span className="font-medium">{info.getValue()}</span> }),
  columnHelper.accessor('email', { header: 'Email', filterFn: 'includesString' }),
  columnHelper.accessor('status', { header: 'Status' }),
  columnHelper.accessor('amount', {
    header: 'Amount',
    sortFn: 'basic',
    meta: { align: 'right' },
    cell: info => currency.format(info.getValue()),
  }),
])

export default function DataTableDemo() {
  const table = useTable({
    features,
    columns,
    data,
    initialState: { pagination: { pageIndex: 0, pageSize: 5 } },
  })

  const selected = table.getFilteredSelectedRowModel().rows.length
  const filtered = table.getFilteredRowModel().rows.length

  return (
    <div className="w-full">
      <div className="flex items-center gap-2 pb-4">
        <Input
          aria-label="Filter emails"
          placeholder="Filter emails…"
          value={(table.getColumn('email')?.getFilterValue() as string) ?? ''}
          onChange={event => table.getColumn('email')?.setFilterValue(event.target.value)}
          className="max-w-xs"
        />
        <DropdownMenu>
          <DropdownMenuTrigger render={<Button variant="outline" className="ml-auto">Columns</Button>} />
          <DropdownMenuContent align="end">
            {table
              .getAllColumns()
              .filter(column => column.getCanHide())
              .map(column => (
                <DropdownMenuCheckboxItem
                  key={column.id}
                  checked={column.getIsVisible()}
                  onCheckedChange={value => column.toggleVisibility(value)}
                  className="capitalize"
                >
                  {column.id}
                </DropdownMenuCheckboxItem>
              ))}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      <div className="rounded-lg border border-border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map(headerGroup => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map(header => {
                  const column = header.column
                  const sorted = column.getIsSorted()
                  const right = (column.columnDef.meta as { align?: string } | undefined)?.align === 'right'
                  return (
                    <TableHead
                      key={header.id}
                      aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined}
                      className={right ? 'text-right' : undefined}
                    >
                      {header.isPlaceholder ? null : column.getCanSort() ? (
                        <Button
                          variant="ghost"
                          size="sm"
                          className={right ? '-mr-3 ml-auto' : '-ml-3'}
                          onClick={column.getToggleSortingHandler()}
                        >
                          <table.FlexRender header={header} />
                          <SortIcon direction={sorted} />
                        </Button>
                      ) : (
                        <table.FlexRender header={header} />
                      )}
                    </TableHead>
                  )
                })}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.length ? (
              table.getRowModel().rows.map(row => (
                <TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined} className="data-[state=selected]:bg-muted/60">
                  {row.getVisibleCells().map(cell => {
                    const right = (cell.column.columnDef.meta as { align?: string } | undefined)?.align === 'right'
                    return (
                      <TableCell key={cell.id} className={right ? 'text-right' : undefined}>
                        <table.FlexRender cell={cell} />
                      </TableCell>
                    )
                  })}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={table.getVisibleLeafColumns().length} className="h-24 text-center text-muted-foreground">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>

      <div className="flex flex-wrap items-center justify-between gap-2 pt-4">
        <p className="text-sm text-muted-foreground" aria-live="polite">
          {selected} of {filtered} row(s) selected.
        </p>
        <div className="flex items-center gap-2">
          <span className="text-sm text-muted-foreground">
            Page {table.state.pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
          </span>
          <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
            Previous
          </Button>
          <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
            Next
          </Button>
        </div>
      </div>
    </div>
  )
}

function SortIcon({ direction }: { direction: false | 'asc' | 'desc' }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={direction ? 'opacity-100' : 'opacity-40'}>
      {direction === 'asc' ? <path d="m18 15-6-6-6 6" /> : direction === 'desc' ? <path d="m6 9 6 6 6-6" /> : <path d="m7 15 5 5 5-5M7 9l5-5 5 5" />}
    </svg>
  )
}

Built on Base UI

Installation

pnpm dlx shadcn@latest add https://kagadmodyaa.vercel.app/r/data-table.json

Needs a React + Tailwind project set up with shadcn. Copies the component source straight into your project, so you can edit it.

Usage

// A data table isn't one component — it's our Table driven by TanStack Table (v9).
// Installing this item copies a complete, editable example into components/data-table.tsx
// along with the table, checkbox, dropdown-menu, button and input it uses.

import DataTable from '@/components/data-table'

export default function Page() {
  return <DataTable />
}

// Then change three things in the file:
// 1. `Payment` and `data` — your row type and rows (or pass them in as props).
// 2. `columns` — one columnHelper.accessor per column; cell() renders, header is the title.
// 3. `features` — register only what you use. Each row-model slot (sorted, filtered, paginated)
//    goes after its feature.

Examples

Minimal: sorting only

Button1.2.08,421
Toast2.0.06,105
Dialog1.0.35,290
Tabs1.1.03,977
'use client'

import {
  createColumnHelper,
  createSortedRowModel,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_basic,
  tableFeatures,
  useTable,
} from '@tanstack/react-table'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'

type Release = { name: string; version: string; downloads: number }

const data: Release[] = [
  { name: 'Button', version: '1.2.0', downloads: 8421 },
  { name: 'Dialog', version: '1.0.3', downloads: 5290 },
  { name: 'Tabs', version: '1.1.0', downloads: 3977 },
  { name: 'Toast', version: '2.0.0', downloads: 6105 },
]

const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
})
const columnHelper = createColumnHelper<typeof features, Release>()
const columns = columnHelper.columns([
  columnHelper.accessor('name', { header: 'Component' }),
  columnHelper.accessor('version', { header: 'Version', enableSorting: false }),
  columnHelper.accessor('downloads', { header: 'Downloads', sortFn: 'basic', cell: info => info.getValue().toLocaleString('en-US') }),
])

/** The smallest useful data table: one feature (sorting) and our Table. Click a header to sort. */
export default function DataTableMinimal() {
  const table = useTable({ features, columns, data, initialState: { sorting: [{ id: 'downloads', desc: true }] } })

  return (
    <div className="w-full rounded-lg border border-border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map(group => (
            <TableRow key={group.id}>
              {group.headers.map(header => (
                <TableHead key={header.id} className="p-0">
                  <button
                    type="button"
                    disabled={!header.column.getCanSort()}
                    onClick={header.column.getToggleSortingHandler()}
                    className="flex h-10 w-full cursor-pointer items-center gap-1 px-2 text-left font-medium outline-none focus-visible:bg-accent disabled:cursor-default"
                  >
                    <table.FlexRender header={header} />
                    {{ asc: '↑', desc: '↓' }[header.column.getIsSorted() as string] ?? null}
                  </button>
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.map(row => (
            <TableRow key={row.id}>
              {row.getAllCells().map(cell => (
                <TableCell key={cell.id}>
                  <table.FlexRender cell={cell} />
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}

API Reference

NameTypeDefaultDescription
featurestableFeatures({ … })—Sorting, column filtering, column visibility, row selection and pagination, each with its row model. Remove what you do not need.
columnHelper.accessor(key, def)ColumnDef—A data column. header is its title, cell renders a value, sortFn / filterFn pick the algorithm, enableSorting / enableHiding opt out.
columnHelper.display({ id, header, cell })ColumnDef—A column with no data of its own, like the checkbox column.
useTable({ features, columns, data })Table—Keep columns and data stable (module scope or memoised). initialState sets page size, sorting and so on.
table.FlexRendercomponent—Renders a header or cell definition: <table.FlexRender header={header} /> or cell={cell}.
Server-side datamanualSorting / manualFiltering / manualPagination—Set the matching manual* option and feed the table already-sorted, filtered or paged data.