Particular.Tech
Data displayui

Data table

Sortable, searchable, paginated table with row selection (TanStack Table v9).

Status
acme-web-1Next.jsReady
120
orbit-api-2AstroReady
8,039
nova-docs-3HonoBuilding
15,958
pulse-worker-4RemixError
23,877
relay-web-5SvelteKitReady
31,796
atlas-api-6Next.jsReady
39,715
acme-docs-7AstroReady
47,634
orbit-worker-8HonoBuilding
5,553
nova-web-9RemixError
13,472
pulse-api-10SvelteKitReady
21,391
34 rows
Page 1 of 4

Installation

npx shadcn@latest add @particular/data-table
npm dependencies
  • @tanstack/react-table@^9.2.4
  • cn@^0.4.0
  • lucide-react@^1.51.0
Installs these registry items

Source

ui/data-table.tsx
"use client"

import * as React from "react"
import {
  columnFilteringFeature,
  createColumnHelper,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  globalFilteringFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_text,
  tableFeatures,
  useTable,
  type Column,
  type ColumnDef,
  type RowData,
} from "@tanstack/react-table"
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { cn } from "cn"

import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"

const dataTableFeatures = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
  columnFilteringFeature,
  globalFilteringFeature,
  filteredRowModel: createFilteredRowModel(),
  filterFns: { includesString: filterFn_includesString },
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
  rowSelectionFeature,
})

type DataTableFeatures = typeof dataTableFeatures

/** Column helper bound to the features DataTable registers. */
function createDataTableColumnHelper<TData extends RowData>() {
  return createColumnHelper<DataTableFeatures, TData>()
}

function DataTableColumnHeader<TData extends RowData, TValue>({
  column,
  title,
  className,
}: {
  column: Column<DataTableFeatures, TData, TValue>
  title: string
  className?: string
}) {
  if (!column.getCanSort()) return <span className={className}>{title}</span>
  const sorted = column.getIsSorted()
  const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : ArrowUpDownIcon
  return (
    <Button
      variant="ghost"
      size="xs"
      className={cn("-ml-2 text-muted-foreground data-[sorted=true]:text-foreground", className)}
      data-sorted={Boolean(sorted)}
      onClick={column.getToggleSortingHandler()}
    >
      {title}
      <Icon data-icon="inline-end" className={cn(!sorted && "opacity-50")} />
    </Button>
  )
}

const pageSizes = [10, 20, 50]

function DataTable<TData extends RowData>({
  columns,
  data,
  getRowId,
  searchPlaceholder = "Search…",
  searchable = true,
  selectable = false,
  initialPageSize = 10,
  toolbar,
  empty = "No results.",
  className,
}: {
  columns: Array<ColumnDef<DataTableFeatures, TData>>
  data: TData[]
  getRowId?: (row: TData, index: number) => string
  searchPlaceholder?: string
  searchable?: boolean
  selectable?: boolean
  initialPageSize?: number
  /** Controls rendered next to the search field, e.g. filters or bulk actions on the selected rows. */
  toolbar?: (context: { selectedRows: TData[]; clearSelection: () => void }) => React.ReactNode
  empty?: React.ReactNode
  className?: string
}) {
  const allColumns = React.useMemo<Array<ColumnDef<DataTableFeatures, TData>>>(() => {
    if (!selectable) return columns
    const select: ColumnDef<DataTableFeatures, TData> = {
      id: "select",
      enableSorting: false,
      enableGlobalFilter: false,
      header: ({ table }) => (
        <Checkbox
          aria-label="Select all rows on this page"
          checked={table.getIsAllPageRowsSelected()}
          indeterminate={table.getIsSomePageRowsSelected()}
          onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
        />
      ),
      cell: ({ row }) => (
        <Checkbox
          aria-label="Select row"
          checked={row.getIsSelected()}
          onCheckedChange={(checked) => row.toggleSelected(checked)}
        />
      ),
    }
    return [select, ...columns]
  }, [columns, selectable])

  const table = useTable({
    features: dataTableFeatures,
    columns: allColumns,
    data,
    getRowId,
    globalFilterFn: "includesString",
    initialState: { pagination: { pageIndex: 0, pageSize: initialPageSize } },
  })

  const selectedRows = table.getSelectedRowModel().rows
  const { pagination, globalFilter } = table.state
  const rows = table.getRowModel().rows
  const filteredCount = table.getFilteredRowModel().rows.length

  return (
    <div data-slot="data-table" className={cn("flex flex-col gap-3", className)}>
      {(searchable || toolbar) && (
        <div className="flex flex-wrap items-center gap-2">
          {searchable && (
            <InputGroup className="w-full max-w-72">
              <InputGroupAddon>
                <SearchIcon />
              </InputGroupAddon>
              <InputGroupInput
                aria-label="Search table"
                placeholder={searchPlaceholder}
                value={typeof globalFilter === "string" ? globalFilter : ""}
                onChange={(event) => table.setGlobalFilter(event.target.value)}
              />
            </InputGroup>
          )}
          {toolbar?.({
            selectedRows: selectedRows.map((row) => row.original),
            clearSelection: () => table.resetRowSelection(true),
          })}
        </div>
      )}
      <div className="overflow-hidden rounded-lg border bg-card">
        <Table>
          <TableHeader className="bg-muted/40">
            {table.getHeaderGroups().map((group) => (
              <TableRow key={group.id}>
                {group.headers.map((header) => (
                  <TableHead
                    key={header.id}
                    aria-sort={
                      header.column.getIsSorted() === "asc"
                        ? "ascending"
                        : header.column.getIsSorted() === "desc"
                          ? "descending"
                          : undefined
                    }
                    className={cn(header.column.id === "select" && "w-10")}
                  >
                    {header.isPlaceholder ? null : <table.FlexRender header={header} />}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {rows.length ? (
              rows.map((row) => (
                <TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>
                  {row.getAllCells().map((cell) => (
                    <TableCell key={cell.id}>
                      <table.FlexRender cell={cell} />
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={allColumns.length} className="h-24 text-center text-muted-foreground">
                  {empty}
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted-foreground">
        <span aria-live="polite">
          {selectable && selectedRows.length > 0
            ? `${selectedRows.length} of ${filteredCount} selected`
            : `${filteredCount} ${filteredCount === 1 ? "row" : "rows"}`}
        </span>
        <div className="flex items-center gap-4">
          <label className="flex items-center gap-2">
            Rows per page
            <NativeSelect
              size="sm"
              value={pagination.pageSize}
              onChange={(event) => table.setPageSize(Number(event.target.value))}
              className="w-16"
            >
              {pageSizes.map((size) => (
                <NativeSelectOption key={size} value={size}>
                  {size}
                </NativeSelectOption>
              ))}
            </NativeSelect>
          </label>
          <span className="tabular-nums">
            Page {pagination.pageIndex + 1} of {Math.max(1, table.getPageCount())}
          </span>
          <div className="flex items-center gap-1">
            <Button
              variant="outline"
              size="icon-sm"
              aria-label="Previous page"
              disabled={!table.getCanPreviousPage()}
              onClick={() => table.previousPage()}
            >
              <ChevronLeftIcon />
            </Button>
            <Button
              variant="outline"
              size="icon-sm"
              aria-label="Next page"
              disabled={!table.getCanNextPage()}
              onClick={() => table.nextPage()}
            >
              <ChevronRightIcon />
            </Button>
          </div>
        </div>
      </div>
    </div>
  )
}

export { DataTable, DataTableColumnHeader, createDataTableColumnHelper, dataTableFeatures, type DataTableFeatures }