HomeDocsComponentsBlocksChartsThemes
Introduction
  • Introduction
  • Installation
  • Themes
  • llms.txt
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Amount
  • Aspect Ratio
  • Audio Player
  • Avatar
  • Avatar Upload
  • Badge
  • Banner
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Code Block
  • Collapsible
  • Color Picker
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Countdown
  • Data Grid
  • Data Table
  • Date Picker
  • Date Range Picker
  • Description List
  • Device Mockup
  • Dialog
  • Direction
  • Dot Loader
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • File Upload
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kanban
  • Kbd
  • Label
  • Menubar
  • Meter
  • Multi Select
  • Native Select
  • Navigation Menu
  • Number Field
  • Pagination
  • Password Strength
  • Phone Input
  • Popover
  • Progress
  • Progress Circle
  • Radio Group
  • Rating
  • Resizable
  • Responsive Dialog
  • Rich Text Editor
  • Scroll Area
  • Segmented Control
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature
  • Skeleton
  • Slider
  • Sortable
  • Spinner
  • Stat
  • Stepper
  • Switch
  • Table
  • Tabs
  • Tag Input
  • Terminal
  • Textarea
  • Time Field
  • Timeline
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Tree
  • Typography
  • Video Player
  • Window Mockup
AI
  • Message Scroller
  • Message
  • Bubble
  • Marker
  • Attachment
  • Questionnaire
  • Composer
  • Reasoning
  • Tool Call
  • Approval Card
  • Citations
Blocks
  • Airtable
  • Airtable Kanban
  • ClickUp List
  • ClickUp Kanban
  • Repeatable Group
  • Command Palette
  • Sign In and Sign Up
  • Availability Scheduler
  • AI Chat
  • Action Bar
  • Notion
  • Banking
  • Lineup Builder
  • Sports Score
  • Strava
  • Sheets
  • Notices
  • Settings
  • Billing
Utilities
  • motion
  • sounds
  • scroll-fade
  • shimmer
Another UI. Built on shadcn/ui with Phosphor icons.

Data Table

PreviousNext

A ready-made data table on TanStack Table with sorting, search, faceted filters, selection and pagination, plus a guide to build your own.

Base UIReact AriaRadix UI
INV-1079
Granville Legal LLPaccounts@granvillelegal.ca
OverdueVisaOct 27, 2026$3,349.31
INV-1046
Rosedale Veterinaryfrontdesk@rosedalevet.ca
PendingWireOct 26, 2026$2,075.64
INV-1057
Okanagan Cellarsorders@okcellars.ca
PaidWireOct 18, 2026$4,629.79
INV-1042
Chilliwack Community Artshello@chilliwackarts.ca
Paide-TransferOct 12, 2026$6,085.51
INV-1054
Granville Legal LLPaccounts@granvillelegal.ca
OverdueChequeOct 12, 2026$8,882.74
INV-1080
Northline Logisticsfinance@northline.ca
PaidVisaOct 12, 2026$3,129.19
INV-1047
Okanagan Cellarsorders@okcellars.ca
Pendinge-TransferOct 9, 2026$384.52
INV-1065
Cascade Roofing Ltd.accounts@cascaderoofing.ca
PendingChequeOct 4, 2026$1,096.18
INV-1045
Maple Ridge Physioadmin@mrphysio.ca
PaidWireSep 24, 2026$3,700.38
INV-1069
Kootenay Outfittersap@kootenayoutfitters.ca
PaidWireSep 24, 2026$2,050.40
0 of 40 selected
Rows per page
  • Page 1 of 4

Installation#

Copy the component. It is built on TanStack Table v9 and the Table, Button, Input Group, Popover, Command, Dropdown Menu, Checkbox, Select, Pagination, Empty and Skeleton primitives.

pnpm add @tanstack/react-table
components/ui/data-table.tsx
"use client"

import * as React from "react"
import {
  CaretDoubleLeftIcon,
  CaretDoubleRightIcon,
  CaretDownIcon,
  CaretLeftIcon,
  CaretRightIcon,
  CaretUpDownIcon,
  CaretUpIcon,
  CheckIcon,
  MagnifyingGlassIcon,
  PlusCircleIcon,
  SlidersHorizontalIcon,
  XIcon,
} from "@phosphor-icons/react"
import {
  columnFacetingFeature,
  columnFilteringFeature,
  columnVisibilityFeature,
  createColumnHelper,
  createFacetedRowModel,
  createFacetedUniqueValues,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  globalFilteringFeature,
  metaHelper,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_basic,
  sortFn_datetime,
  sortFn_text,
  tableFeatures,
  useTable,
  type Column,
  type ColumnDef,
  type ColumnFiltersState,
  type ColumnVisibilityState,
  type FilterFn,
  type ReactTable,
  type Row,
  type RowData,
  type RowSelectionState,
  type SortingState,
} from "@tanstack/react-table"

import { NumberPop } from "@/components/ui/motion"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { cue } from "@/lib/sounds"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
  CommandShortcut,
} from "@/components/ui/command"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "cn"

/*
 * Data Table: a full table on TanStack Table v9 and the Table primitive.
 * Pass columns and data and you get sorting, a global search, faceted
 * filters with counts, column visibility, row selection, pagination with a
 * rows per page Select, an Empty state and Skeleton loading rows. The
 * toolbar keeps every control on one row and scrolls sideways on a phone,
 * and the table scrolls inside its own frame so the page never does.
 *
 * Consumers write column defs only. Use `dataTableColumns<T>()` for typed
 * helpers, `DataTableColumnHeader` for a sortable header, and put
 * `meta.filterOptions` on a column to get a faceted filter for it.
 */

/* ---------------------------------------------------------------- features */

export type DataTableFilterOption = {
  label: string
  value: string
  icon?: React.ComponentType<{ className?: string }>
}

export type DataTableColumnMeta = {
  /** Name shown in the View menu and filter buttons. Defaults to the column id. */
  label?: string
  /** Turns on a faceted filter in the toolbar for this column. */
  filterOptions?: DataTableFilterOption[]
  /** Classes for the header and every cell, e.g. "text-right". */
  className?: string
}

/** Keeps rows whose value is one of the picked values. */
const filterFn_facet: FilterFn<any, any> = (row, columnId, value) => {
  if (!Array.isArray(value) || value.length === 0) return true
  return value.includes(String(row.getValue(columnId)))
}
filterFn_facet.autoRemove = (value) => !Array.isArray(value) || value.length === 0

export const dataTableFeatures = tableFeatures({
  columnFacetingFeature,
  columnFilteringFeature,
  columnVisibilityFeature,
  globalFilteringFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  facetedRowModel: createFacetedRowModel(),
  facetedUniqueValues: createFacetedUniqueValues(),
  filteredRowModel: createFilteredRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  sortedRowModel: createSortedRowModel(),
  filterFns: { includesString: filterFn_includesString, facet: filterFn_facet },
  sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text, basic: sortFn_basic, datetime: sortFn_datetime },
  columnMeta: metaHelper<DataTableColumnMeta>(),
})

export type DataTableFeatures = typeof dataTableFeatures
export type DataTableColumnDef<TData extends RowData> = ColumnDef<DataTableFeatures, TData, any>
export type DataTableInstance<TData extends RowData> = ReactTable<DataTableFeatures, TData>
type DataTableColumn<TData extends RowData, TValue = unknown> = Column<DataTableFeatures, TData, TValue>

/** Typed column helper: `const col = dataTableColumns<Invoice>()`, then `col.accessor(...)`. */
export function dataTableColumns<TData extends RowData>() {
  return createColumnHelper<DataTableFeatures, TData>()
}

function columnLabel<TData extends RowData>(column: DataTableColumn<TData, any>) {
  return column.columnDef.meta?.label ?? column.id
}

/* ----------------------------------------------------------- column header */

export function DataTableColumnHeader<TData extends RowData, TValue>({
  column,
  title,
  className,
}: {
  column: DataTableColumn<TData, TValue>
  title: string
  className?: string
}) {
  if (!column.getCanSort()) {
    return (
      <span data-slot="data-table-column-header" className={className}>
        {title}
      </span>
    )
  }
  const sorted = column.getIsSorted()
  const Icon = sorted === "asc" ? CaretUpIcon : sorted === "desc" ? CaretDownIcon : CaretUpDownIcon
  const next = sorted === "asc" ? "descending" : sorted === "desc" ? "not sorted" : "ascending"
  return (
    <Button
      data-slot="data-table-column-header"
      data-sorted={sorted || undefined}
      type="button"
      variant="ghost"
      size="sm"
      aria-label={`${title}, sort ${next}`}
      className={cn(
        "-mx-2 h-7 gap-1 px-2 font-medium in-[.text-right]:flex-row-reverse data-sorted:text-foreground",
        className
      )}
      onClick={() => {
        cue("tap")
        column.toggleSorting()
      }}
    >
      <span>{title}</span>
      <Icon
        weight="bold"
        data-slot="data-table-sort-icon"
        className={cn("size-3.5", sorted ? "text-foreground" : "text-muted-foreground/70")}
      />
    </Button>
  )
}

/* ---------------------------------------------------------- faceted filter */

export function DataTableFacetedFilter<TData extends RowData, TValue>({
  column,
  title,
  options,
}: {
  column?: DataTableColumn<TData, TValue>
  title?: string
  options: DataTableFilterOption[]
}) {
  const facets = column?.getFacetedUniqueValues()
  const selected = new Set((column?.getFilterValue() as string[] | undefined) ?? [])

  const toggle = (value: string) => {
    const next = new Set(selected)
    if (next.has(value)) next.delete(value)
    else next.add(value)
    cue("select")
    column?.setFilterValue(next.size ? Array.from(next) : undefined)
  }

  return (
    <Popover onOpenChange={(open) => cue(open ? "open" : "close")}>
      <PopoverTrigger asChild>
        <Button
          data-slot="data-table-faceted-filter"
          data-active={selected.size > 0 || undefined}
          type="button"
          variant="outline"
          size="sm"
          className="shrink-0 border-dashed"
        >
          <PlusCircleIcon weight="duotone" />
          {title}
          {selected.size > 0 && (
            <>
              <Separator orientation="vertical" className="mx-0.5 h-3.5!" />
              {selected.size > 2 ? (
                <Badge variant="secondary" className="rounded-sm px-1 font-normal">
                  <NumberPop value={selected.size} /> selected
                </Badge>
              ) : (
                options
                  .filter((option) => selected.has(option.value))
                  .map((option) => (
                    <Badge key={option.value} variant="secondary" className="rounded-sm px-1 font-normal">
                      {option.label}
                    </Badge>
                  ))
              )}
            </>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent data-slot="data-table-faceted-filter-content" className="w-52 p-0" align="start">
        <Command>
          <CommandInput placeholder={title} />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup>
              {options.map((option) => {
                const isSelected = selected.has(option.value)
                const count = facets?.get(option.value) ?? 0
                return (
                  <CommandItem key={option.value} value={option.label} onSelect={() => toggle(option.value)}>
                    <span
                      data-slot="data-table-faceted-filter-check"
                      data-selected={isSelected || undefined}
                      className={cn(
                        "flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors duration-(--duration-quick)",
                        isSelected
                          ? "border-primary bg-primary text-primary-foreground"
                          : "border-input [&_svg]:invisible"
                      )}
                    >
                      <CheckIcon weight="bold" className="size-3 text-primary-foreground" />
                    </span>
                    {option.icon && <option.icon className="size-4 text-muted-foreground" />}
                    <span className="truncate">{option.label}</span>
                    <CommandShortcut className="font-mono tracking-normal tabular-nums">
                      {count}
                    </CommandShortcut>
                  </CommandItem>
                )
              })}
            </CommandGroup>
            {selected.size > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem
                    onSelect={() => {
                      cue("close")
                      column?.setFilterValue(undefined)
                    }}
                    className="justify-center text-center"
                  >
                    Clear filters
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

/* ------------------------------------------------------------ view options */

export function DataTableViewOptions<TData extends RowData>({ table }: { table: DataTableInstance<TData> }) {
  const columns = table
    .getAllColumns()
    .filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
  if (columns.length === 0) return null
  return (
    <DropdownMenu onOpenChange={(open) => cue(open ? "open" : "close")}>
      <DropdownMenuTrigger asChild>
        <Button data-slot="data-table-view-options" type="button" variant="outline" size="sm" className="shrink-0">
          <SlidersHorizontalIcon weight="duotone" />
          View
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-44">
        <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {columns.map((column) => (
          <DropdownMenuCheckboxItem
            key={column.id}
            checked={column.getIsVisible()}
            onSelect={(event) => event.preventDefault()}
            onCheckedChange={(value) => {
              cue("toggle")
              column.toggleVisibility(!!value)
            }}
          >
            {columnLabel(column)}
          </DropdownMenuCheckboxItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

/* ----------------------------------------------------------------- toolbar */

export function DataTableToolbar<TData extends RowData>({
  table,
  searchPlaceholder = "Search...",
  search = true,
  children,
  className,
}: {
  table: DataTableInstance<TData>
  searchPlaceholder?: string
  search?: boolean
  /** Extra controls, placed at the end of the row next to View. */
  children?: React.ReactNode
  className?: string
}) {
  const globalFilter = (table.state.globalFilter as string | undefined) ?? ""
  const isFiltered = table.state.columnFilters.length > 0 || globalFilter.length > 0
  const facetColumns = table.getAllColumns().filter((column) => column.columnDef.meta?.filterOptions)

  return (
    <div
      data-slot="data-table-toolbar"
      className={cn(
        // Room above and below so button shadows are not clipped by the scroll box.
        "-mx-1 -my-2 flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto px-1 py-2 scroll-fade-x [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
        className
      )}
    >
      {search && (
        <InputGroup data-slot="data-table-search" className="w-44 shrink-0 sm:w-64">
          <InputGroupAddon>
            <MagnifyingGlassIcon weight="duotone" />
          </InputGroupAddon>
          <InputGroupInput
            aria-label={searchPlaceholder}
            placeholder={searchPlaceholder}
            value={globalFilter}
            onChange={(event) => table.setGlobalFilter(event.target.value)}
          />
          {globalFilter && (
            <InputGroupAddon align="inline-end">
              <InputGroupButton
                size="icon-xs"
                aria-label="Clear search"
                onClick={() => {
                  cue("close")
                  table.setGlobalFilter("")
                }}
              >
                <XIcon weight="bold" />
              </InputGroupButton>
            </InputGroupAddon>
          )}
        </InputGroup>
      )}
      {facetColumns.map((column) => (
        <DataTableFacetedFilter
          key={column.id}
          column={column}
          title={columnLabel(column)}
          options={column.columnDef.meta!.filterOptions!}
        />
      ))}
      {isFiltered && (
        <Button
          type="button"
          variant="ghost"
          size="sm"
          className="shrink-0"
          onClick={() => {
            cue("close")
            table.resetColumnFilters()
            table.setGlobalFilter("")
          }}
        >
          Reset
          <XIcon weight="bold" />
        </Button>
      )}
      <div data-slot="data-table-toolbar-actions" className="ml-auto flex shrink-0 items-center gap-2">
        {children}
        <DataTableViewOptions table={table} />
      </div>
    </div>
  )
}

/* -------------------------------------------------------------- pagination */

export function DataTablePagination<TData extends RowData>({
  table,
  pageSizes = [10, 20, 30, 50],
  className,
}: {
  table: DataTableInstance<TData>
  pageSizes?: number[]
  className?: string
}) {
  const { pageIndex, pageSize } = table.state.pagination
  const pageCount = Math.max(1, table.getPageCount())
  const selectable = table.options.enableRowSelection !== false
  const go = (fn: () => void) => () => {
    cue("navigate")
    fn()
  }

  return (
    <div
      data-slot="data-table-pagination"
      className={cn("flex flex-wrap items-center justify-between gap-x-4 gap-y-2 text-sm", className)}
    >
      <div data-slot="data-table-selection-count" className="text-muted-foreground tabular-nums">
        {selectable
          ? `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`
          : `${table.getFilteredRowModel().rows.length} rows`}
      </div>
      <div className="ml-auto flex items-center gap-3 sm:gap-6">
        <div className="flex items-center gap-2">
          <span className="hidden font-medium sm:inline">Rows per page</span>
          <Select
            value={`${pageSize}`}
            onValueChange={(value) => {
              cue("select")
              table.setPageSize(Number(value))
            }}
          >
            <SelectTrigger size="sm" aria-label="Rows per page" className="w-17">
              <SelectValue />
            </SelectTrigger>
            <SelectContent side="top">
              {pageSizes.map((size) => (
                <SelectItem key={size} value={`${size}`}>
                  {size}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <Pagination className="mx-0 w-auto">
          <PaginationContent className="gap-1">
            <PaginationItem className="hidden sm:block">
              <Button
                type="button"
                variant="outline"
                size="icon-sm"
                aria-label="Go to first page"
                disabled={!table.getCanPreviousPage()}
                onClick={go(() => table.setPageIndex(0))}
              >
                <CaretDoubleLeftIcon weight="bold" className="cn-rtl-flip" />
              </Button>
            </PaginationItem>
            <PaginationItem>
              <Button
                type="button"
                variant="outline"
                size="icon-sm"
                aria-label="Go to previous page"
                disabled={!table.getCanPreviousPage()}
                onClick={go(() => table.previousPage())}
              >
                <CaretLeftIcon weight="bold" className="cn-rtl-flip" />
              </Button>
            </PaginationItem>
            <PaginationItem
              data-slot="data-table-page"
              aria-live="polite"
              className="px-1.5 font-medium whitespace-nowrap tabular-nums"
            >
              Page <NumberPop value={pageIndex + 1} /> of {pageCount}
            </PaginationItem>
            <PaginationItem>
              <Button
                type="button"
                variant="outline"
                size="icon-sm"
                aria-label="Go to next page"
                disabled={!table.getCanNextPage()}
                onClick={go(() => table.nextPage())}
              >
                <CaretRightIcon weight="bold" className="cn-rtl-flip" />
              </Button>
            </PaginationItem>
            <PaginationItem className="hidden sm:block">
              <Button
                type="button"
                variant="outline"
                size="icon-sm"
                aria-label="Go to last page"
                disabled={!table.getCanNextPage()}
                onClick={go(() => table.setPageIndex(table.getPageCount() - 1))}
              >
                <CaretDoubleRightIcon weight="bold" className="cn-rtl-flip" />
              </Button>
            </PaginationItem>
          </PaginationContent>
        </Pagination>
      </div>
    </div>
  )
}

/* -------------------------------------------------------- selection column */

function selectionColumn<TData extends RowData>(): DataTableColumnDef<TData> {
  return {
    id: "select",
    header: ({ table }) => (
      <Checkbox
        checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
        onCheckedChange={(value) => {
          cue("toggle")
          table.toggleAllPageRowsSelected(!!value)
        }}
        aria-label="Select all rows on this page"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        disabled={!row.getCanSelect()}
        onCheckedChange={(value) => {
          cue("toggle")
          row.toggleSelected(!!value)
        }}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
    enableGlobalFilter: false,
    meta: { className: "w-8" },
  }
}

/* -------------------------------------------------------------- data table */

export type DataTableProps<TData extends RowData> = {
  columns: DataTableColumnDef<TData>[]
  data: TData[]
  /** Adds a checkbox column for row selection. */
  selectable?: boolean
  /** Shows Skeleton rows in place of data. */
  loading?: boolean
  /** Number of Skeleton rows while loading. Defaults to 5. */
  loadingRows?: number
  /** Turns the search box off. */
  search?: boolean
  searchPlaceholder?: string
  pageSizes?: number[]
  initialPageSize?: number
  initialSorting?: SortingState
  /** Stable row ids, so selection survives sorting and new data. */
  getRowId?: (row: TData, index: number) => string
  /** Called with the selected rows whenever selection changes. */
  onSelectionChange?: (rows: TData[]) => void
  /** Extra controls at the end of the toolbar row. Gets the table instance. */
  toolbar?: React.ReactNode | ((table: DataTableInstance<TData>) => React.ReactNode)
  /** Replaces the default empty state. */
  empty?: React.ReactNode
  /** Tallest the table body gets before it scrolls, with the header pinned. Defaults to 30rem. */
  maxHeight?: string
  className?: string
}

export function DataTable<TData extends RowData>({
  columns,
  data,
  selectable = false,
  loading = false,
  loadingRows = 5,
  search = true,
  searchPlaceholder,
  pageSizes = [10, 20, 30, 50],
  initialPageSize,
  initialSorting = [],
  getRowId,
  onSelectionChange,
  toolbar,
  empty,
  maxHeight = "30rem",
  className,
}: DataTableProps<TData>) {
  const [sorting, setSorting] = React.useState<SortingState>(initialSorting)
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
  const [columnVisibility, setColumnVisibility] = React.useState<ColumnVisibilityState>({})
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
  const [globalFilter, setGlobalFilter] = React.useState("")

  const allColumns = React.useMemo(() => {
    // Columns with filter options filter by "one of the picked values".
    const withFacets = columns.map((column) =>
      column.meta?.filterOptions && !column.filterFn ? { ...column, filterFn: "facet" as const } : column
    )
    return selectable ? [selectionColumn<TData>(), ...withFacets] : withFacets
  }, [columns, selectable])

  const table = useTable({
    features: dataTableFeatures,
    data,
    columns: allColumns,
    getRowId,
    state: { sorting, columnFilters, columnVisibility, rowSelection, globalFilter },
    initialState: { pagination: { pageIndex: 0, pageSize: initialPageSize ?? pageSizes[0] ?? 10 } },
    enableRowSelection: selectable,
    globalFilterFn: "includesString",
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    onGlobalFilterChange: setGlobalFilter,
  })

  // Report selection to the consumer after it settles.
  const onSelectionRef = React.useRef(onSelectionChange)
  onSelectionRef.current = onSelectionChange
  React.useEffect(() => {
    onSelectionRef.current?.(table.getSelectedRowModel().rows.map((row: Row<DataTableFeatures, TData>) => row.original))
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rowSelection, data])

  // When loading ends, rows fade in once with the skeleton reveal.
  const [revealing, setRevealing] = React.useState(false)
  const wasLoading = React.useRef(loading)
  React.useEffect(() => {
    if (wasLoading.current && !loading) {
      setRevealing(true)
      const id = window.setTimeout(() => setRevealing(false), 700)
      wasLoading.current = loading
      return () => window.clearTimeout(id)
    }
    wasLoading.current = loading
  }, [loading])

  const rows = table.getRowModel().rows
  const visibleColumns = table.getVisibleLeafColumns()
  const isFiltered = columnFilters.length > 0 || globalFilter.length > 0
  const extra = typeof toolbar === "function" ? toolbar(table) : toolbar

  return (
    <div data-slot="data-table" className={cn("flex w-full min-w-0 flex-col gap-3", className)}>
      <DataTableToolbar table={table} search={search} searchPlaceholder={searchPlaceholder}>
        {extra}
      </DataTableToolbar>
      <div
        data-slot="data-table-frame"
        style={{ "--data-table-height": maxHeight } as React.CSSProperties}
        className={cn(
          "min-w-0 overflow-hidden rounded-lg border"
        )}
      >
        {/* Scrolls both ways inside the frame. globals.css fades the edges with more to show, on the viewport only, so the scrollbars stay opaque. */}
        <ScrollFadeArea fade="none" scrollbars="both" className="max-h-(--data-table-height) overscroll-contain">
          <table data-slot="table" aria-busy={loading || undefined} className="w-full caption-bottom text-sm">
            <TableHeader className="sticky top-0 z-10 bg-[color-mix(in_oklab,var(--muted)_40%,var(--background))]">
              {table.getHeaderGroups().map((headerGroup) => (
                <TableRow key={headerGroup.id} className="hover:bg-transparent">
                  {headerGroup.headers.map((header) => {
                    const sorted = header.column.getIsSorted()
                    return (
                      <TableHead
                        key={header.id}
                        colSpan={header.colSpan}
                        aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
                        className={cn("px-3", header.column.columnDef.meta?.className)}
                      >
                        {header.isPlaceholder ? null : <table.FlexRender header={header} />}
                      </TableHead>
                    )
                  })}
                </TableRow>
              ))}
            </TableHeader>
            <TableBody data-reveal={revealing || undefined} className="t-data-table-body">
              {loading ? (
                Array.from({ length: loadingRows }, (_, i) => (
                  <TableRow key={`loading-${i}`} data-slot="data-table-loading-row" className="hover:bg-transparent">
                    {visibleColumns.map((column, j) => (
                      <TableCell key={column.id} className={cn("px-3", column.columnDef.meta?.className)}>
                        <Skeleton
                          className={cn(
                            "h-4",
                            column.id === "select" ? "size-4" : ["w-24", "w-32", "w-20", "w-28", "w-16"][(i + j) % 5],
                            column.columnDef.meta?.className?.includes("text-right") && "ml-auto"
                          )}
                        />
                      </TableCell>
                    ))}
                  </TableRow>
                ))
              ) : rows.length ? (
                rows.map((row) => (
                  <TableRow
                    key={row.id}
                    data-slot="data-table-row"
                    data-state={row.getIsSelected() ? "selected" : undefined}
                  >
                    {row.getVisibleCells().map((cell) => (
                      <TableCell key={cell.id} className={cn("px-3", cell.column.columnDef.meta?.className)}>
                        <table.FlexRender cell={cell} />
                      </TableCell>
                    ))}
                  </TableRow>
                ))
              ) : (
                <TableRow className="hover:bg-transparent">
                  <TableCell colSpan={visibleColumns.length} className="p-0 whitespace-normal">
                    {empty ?? (
                      <Empty data-slot="data-table-empty" className="py-10">
                        <EmptyHeader>
                          <EmptyMedia variant="icon">
                            <MagnifyingGlassIcon weight="duotone" />
                          </EmptyMedia>
                          <EmptyTitle>{isFiltered ? "No matching rows" : "Nothing here yet"}</EmptyTitle>
                          <EmptyDescription>
                            {isFiltered ? "Try a different search or clear the filters." : "Rows show up here once there is data."}
                          </EmptyDescription>
                        </EmptyHeader>
                        {isFiltered && (
                          <EmptyContent>
                            <Button
                              type="button"
                              variant="outline"
                              size="sm"
                              onClick={() => {
                                cue("close")
                                table.resetColumnFilters()
                                table.setGlobalFilter("")
                              }}
                            >
                              Clear filters
                            </Button>
                          </EmptyContent>
                        )}
                      </Empty>
                    )}
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </table>
        </ScrollFadeArea>
      </div>
      <DataTablePagination table={table} pageSizes={pageSizes} />
    </div>
  )
}

Usage#

You write the column defs. The table brings the toolbar, sorting, filters, selection and pagination.

import {
  DataTable,
  DataTableColumnHeader,
  dataTableColumns,
} from "@/components/ui/data-table"
 
type Invoice = { id: string; client: string; status: string; amount: number }
 
const col = dataTableColumns<Invoice>()
 
const columns = col.columns([
  col.accessor("id", {
    header: ({ column }) => <DataTableColumnHeader column={column} title="Invoice" />,
  }),
  col.accessor("client", {
    header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />,
    meta: { label: "Client" },
  }),
  col.accessor("status", {
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    meta: {
      label: "Status",
      filterOptions: [
        { value: "paid", label: "Paid" },
        { value: "overdue", label: "Overdue" },
      ],
    },
  }),
  col.accessor("amount", {
    header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
    meta: { label: "Amount", className: "text-right" },
  }),
])
 
export function Invoices({ data }: { data: Invoice[] }) {
  return <DataTable columns={columns} data={data} selectable getRowId={(row) => row.id} />
}

Column meta#

  • label names the column in the View menu and on its filter button.
  • filterOptions adds a faceted filter to the toolbar. It is a Popover with a Command list, multi select, with a count next to each option.
  • className goes on the header and every cell, for example text-right for money.

Props#

  • columns and data. Columns are TanStack column defs, typed with dataTableColumns<T>().
  • selectable adds a checkbox column. onSelectionChange gets the selected rows.
  • loading swaps the body for Skeleton rows (loadingRows, default 5). Rows fade in when it turns off.
  • search and searchPlaceholder control the global search. Number and date columns can opt out with enableGlobalFilter: false.
  • pageSizes, initialPageSize and initialSorting set the starting state.
  • toolbar puts your own buttons at the end of the toolbar row. Pass a function to get the table instance.
  • empty replaces the default Empty state.

Parts#

The pieces are exported too, for a table you wire yourself: DataTableColumnHeader, DataTableToolbar, DataTablePagination, DataTableViewOptions and DataTableFacetedFilter. They take the table from useTable with dataTableFeatures.

Mobile#

The toolbar keeps every control on one row and scrolls sideways when it runs out of room. The table scrolls inside its own frame, so the page never scrolls sideways.

Build your own#

The rest of this page is the guide to building a data table from scratch with TanStack Table and the <Table /> component. Use it when the ready-made table does not fit.

Introduction#

Every data table or datagrid I've created has been unique. They all behave differently, have specific sorting and filtering requirements, and work with different data sources.

It doesn't make sense to combine all of these variations into a single component. If we do that, we'll lose the flexibility that headless UI provides.

So instead of a data-table component, I thought it would be more helpful to provide a guide on how to build your own.

We'll start with the basic <Table /> component and build a complex data table from scratch.

Tip: If you find yourself using the same table in multiple places in your app, you can always extract it into a reusable component.

Table of Contents#

This guide will show you how to use TanStack Table and the <Table /> component to build your own custom data table. We'll cover the following topics:

  • Set up Table Features
  • Basic Table
  • Row Actions
  • Pagination
  • Sorting
  • Filtering
  • Visibility
  • Row Selection
  • Reusable Components

Installation#

  1. Add the <Table /> component to your project:
pnpm dlx shadcn@latest add https://anotherui.dev/r/table.json
  1. Add the @tanstack/react-table dependency. This guide uses TanStack Table v9:
pnpm add @tanstack/react-table

Prerequisites#

We are going to build a table to show recent payments. Here's what our data looks like:

type Payment = {
  id: string
  amount: number
  status: "pending" | "processing" | "success" | "failed"
  email: string
}
 
export const payments: Payment[] = [
  {
    id: "728ed52f",
    amount: 100,
    status: "pending",
    email: "m@example.com",
  },
  {
    id: "489e1d42",
    amount: 125,
    status: "processing",
    email: "example@gmail.com",
  },
  // ...
]

Project Structure#

Start by creating the following file structure:

app
└── payments
    ├── columns.tsx
    ├── data-table-features.ts
    ├── data-table.tsx
    └── page.tsx

I'm using a Next.js example here but this works for any other React framework.

  • columns.tsx (client component) will contain our column definitions.
  • data-table-features.ts will contain the shared features object that tells TanStack Table which behavior to enable.
  • data-table.tsx (client component) will contain our <DataTable /> component.
  • page.tsx (server component) is where we'll fetch data and render our table.

Set up Table Features#

TanStack Table v9 is feature-based: you opt into the behavior you want, sorting, filtering, pagination, and so on, by declaring it with tableFeatures(). Anything you don't list is tree-shaken out of your bundle. That includes the built-in filter and sort functions: register the ones your columns rely on under filterFns and sortFns (our email filter uses includesString, and string columns sort with text / alphanumeric).

app/payments/data-table-features.ts
import {
  columnFilteringFeature,
  columnVisibilityFeature,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_text,
  tableFeatures,
} from "@tanstack/react-table"
 
// New in v9: declare the features this table uses, anything you don't
// register is tree-shaken out of the bundle.
export const features = tableFeatures({
  columnFilteringFeature,
  columnVisibilityFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  filteredRowModel: createFilteredRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  sortedRowModel: createSortedRowModel(),
  filterFns: { includesString: filterFn_includesString },
  sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
})
 
// Pass this as the first generic argument to `ColumnDef`, `Column`, `Table`,
// and `Row` so each type knows which feature APIs are available.
export type DataTableFeatures = typeof features

Note: The core row model is always included, so you never register it yourself. Row models for optional features are created with create*RowModel() and registered on the features object, there are no more get*RowModel table options.

Basic Table#

Let's start by building a basic table.

Column Definitions#

First, we'll define our columns.

app/payments/columns.tsx
"use client"
 
import { createColumnHelper } from "@tanstack/react-table"
 
import { type DataTableFeatures } from "./data-table-features"
 
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Payment = {
  id: string
  amount: number
  status: "pending" | "processing" | "success" | "failed"
  email: string
}
 
// Use `accessor` for data columns and `display` for columns without one.
const columnHelper = createColumnHelper<DataTableFeatures, Payment>()
 
export const columns = columnHelper.columns([
  columnHelper.accessor("status", {
    header: "Status",
  }),
  columnHelper.accessor("email", {
    header: "Email",
  }),
  columnHelper.accessor("amount", {
    header: "Amount",
  }),
])

Note: Columns are where you define the core of what your table will look like. They define the data that will be displayed, how it will be formatted, sorted and filtered.

<DataTable /> component#

Next, we'll create a <DataTable /> component to render our table.

app/payments/data-table.tsx
"use client"
 
import { useTable, type ColumnDef, type RowData } from "@tanstack/react-table"
 
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
 
import { features, type DataTableFeatures } from "./data-table-features"
 
interface DataTableProps<TData extends RowData> {
  columns: ColumnDef<DataTableFeatures, TData>[]
  data: TData[]
}
 
export function DataTable<TData extends RowData>({
  columns,
  data,
}: DataTableProps<TData>) {
  const table = useTable({
    features,
    data,
    columns,
  })
 
  return (
    <div className="overflow-hidden rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder ? null : (
                      <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"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    <table.FlexRender cell={cell} />
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  )
}

Tip: If you find yourself using <DataTable /> in multiple places, this is the component you could make reusable by extracting it to components/ui/data-table.tsx.

<DataTable columns={columns} data={data} />

<table.FlexRender /> vs flexRender: This guide uses v9's <table.FlexRender header={header} /> and <table.FlexRender cell={cell} /> component, available right on the table instance, no extra import needed. The classic flexRender(component, context) helper from v8 still works too, if you prefer the function form (or need to render outside the component that owns table, where you can also import the standalone <FlexRender />).

Render the table#

Finally, we'll render our table in our page component.

app/payments/page.tsx
import { columns, Payment } from "./columns"
import { DataTable } from "./data-table"
 
async function getData(): Promise<Payment[]> {
  // Fetch data from your API here.
  return [
    {
      id: "728ed52f",
      amount: 100,
      status: "pending",
      email: "m@example.com",
    },
    // ...
  ]
}
 
export default async function DemoPage() {
  const data = await getData()
 
  return (
    <div className="container mx-auto py-10">
      <DataTable columns={columns} data={data} />
    </div>
  )
}

Cell Formatting#

Let's format the amount cell to display the dollar amount. We'll also align the cell to the right.

Update columns definition#

Update the header and cell definitions for amount as follows:

app/payments/columns.tsx
export const columns = columnHelper.columns([
  columnHelper.accessor("amount", {
    header: () => <div className="text-right">Amount</div>,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue("amount"))
      const formatted = new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(amount)
 
      return <div className="text-right font-medium">{formatted}</div>
    },
  }),
])

You can use the same approach to format other cells and headers.

Row Actions#

Let's add row actions to our table. We'll use a <DropdownMenu /> component for this.

Update columns definition#

Update our columns definition to add a new actions column. The actions cell returns a <DropdownMenu /> component.

app/payments/columns.tsx
"use client"
 
import { createColumnHelper } from "@tanstack/react-table"
import { ArrowsDownUpIcon, DotsThreeOutlineIcon } from "@phosphor-icons/react/ssr"
 
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
 
export const columns = columnHelper.columns([
  // ...
  columnHelper.display({
    id: "actions",
    cell: ({ row }) => {
      const payment = row.original
 
      return (
        <DropdownMenu>
          <DropdownMenuTrigger
            render={<Button variant="ghost" className="h-8 w-8 p-0" />}
          >
            <span className="sr-only">Open menu</span>
            <DotsThreeOutlineIcon weight="bold" className="h-4 w-4" />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuLabel>Actions</DropdownMenuLabel>
            <DropdownMenuItem
              onClick={() => navigator.clipboard.writeText(payment.id)}
            >
              Copy payment ID
            </DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem>View customer</DropdownMenuItem>
            <DropdownMenuItem>View payment details</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    },
  }),
  // ...
])

You can access the row data using row.original in the cell function. Use this to handle actions for your row eg. use the id to make a DELETE call to your API.

Pagination#

Next, we'll add pagination to our table.

Pagination is already enabled#

Because our features object includes rowPaginationFeature and createPaginatedRowModel(), the table automatically paginates rows into pages of 10, there's nothing to add to useTable. See the pagination docs for more information on customizing page size and implementing manual pagination.

Add pagination controls#

We can add pagination controls to our table using the <Button /> component and the table.previousPage(), table.nextPage() API methods.

app/payments/data-table.tsx
import { Button } from "@/components/ui/button"
 
export function DataTable<TData extends RowData>({
  columns,
  data,
}: DataTableProps<TData>) {
  const table = useTable({
    features,
    data,
    columns,
  })
 
  return (
    <div>
      <div className="overflow-hidden rounded-md border">
        <Table>
          { // .... }
        </Table>
      </div>
      <div className="flex items-center justify-end space-x-2 py-4">
        <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>
  )
}

See Reusable Components section for a more advanced pagination component.

Sorting#

Let's make the email column sortable.

The rowSortingFeature and sorted row model are already registered in our features object, so all that's left is wiring up the sorting state.

Update <DataTable>#

app/payments/data-table.tsx
"use client"
 
import * as React from "react"
import {
  useTable,
  type ColumnDef,
  type RowData,
  type SortingState,
} from "@tanstack/react-table"
 
export function DataTable<TData extends RowData>({
  columns,
  data,
}: DataTableProps<TData>) {
  const [sorting, setSorting] = React.useState<SortingState>([])
 
  const table = useTable({
    features,
    data,
    columns,
    onSortingChange: setSorting,
    state: {
      sorting,
    },
  })
 
  return (
    <div>
      <div className="overflow-hidden rounded-md border">
        <Table>{ ... }</Table>
      </div>
    </div>
  )
}

Make header cell sortable#

We can now update the email header cell to add sorting controls.

app/payments/columns.tsx
"use client"
 
import { createColumnHelper } from "@tanstack/react-table"
 
export const columns = columnHelper.columns([
  columnHelper.accessor("email", {
    header: ({ column }) => {
      return (
        <Button
          variant="ghost"
          onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
        >
          Email
          <ArrowsDownUpIcon weight="bold" className="ml-2 h-4 w-4" />
        </Button>
      )
    },
  }),
])

This will automatically sort the table (asc and desc) when the user toggles on the header cell.

Filtering#

Let's add a search input to filter emails in our table.

The columnFilteringFeature and filtered row model are already part of our features object, so we only need to wire up the filter state and render an input.

Update <DataTable>#

app/payments/data-table.tsx
"use client"
 
import * as React from "react"
import {
  useTable,
  type ColumnDef,
  type ColumnFiltersState,
  type RowData,
  type SortingState,
} from "@tanstack/react-table"
 
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
 
export function DataTable<TData extends RowData>({
  columns,
  data,
}: DataTableProps<TData>) {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  )
 
  const table = useTable({
    features,
    data,
    columns,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: {
      sorting,
      columnFilters,
    },
  })
 
  return (
    <div>
      <div className="flex items-center py-4">
        <Input
          placeholder="Filter emails..."
          value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
          onChange={(event) =>
            table.getColumn("email")?.setFilterValue(event.target.value)
          }
          className="max-w-sm"
        />
      </div>
      <div className="overflow-hidden rounded-md border">
        <Table>{ ... }</Table>
      </div>
    </div>
  )
}

Filtering is now enabled for the email column. You can add filters to other columns as well. See the filtering docs for more information on customizing filters.

Visibility#

Adding column visibility is fairly simple using @tanstack/react-table visibility API.

Update <DataTable>#

app/payments/data-table.tsx
"use client"
 
import * as React from "react"
import {
  useTable,
  type ColumnDef,
  type ColumnFiltersState,
  type ColumnVisibilityState,
  type RowData,
  type SortingState,
} from "@tanstack/react-table"
 
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
 
export function DataTable<TData extends RowData>({
  columns,
  data,
}: DataTableProps<TData>) {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  )
  const [columnVisibility, setColumnVisibility] =
    React.useState<ColumnVisibilityState>({})
 
  const table = useTable({
    features,
    data,
    columns,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    state: {
      sorting,
      columnFilters,
      columnVisibility,
    },
  })
 
  return (
    <div>
      <div className="flex items-center py-4">
        <Input
          placeholder="Filter emails..."
          value={table.getColumn("email")?.getFilterValue() as string}
          onChange={(event) =>
            table.getColumn("email")?.setFilterValue(event.target.value)
          }
          className="max-w-sm"
        />
        <DropdownMenu>
          <DropdownMenuTrigger render={<Button variant="outline" className="ml-auto" />}>
            Columns
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            {table
              .getAllColumns()
              .filter(
                (column) => column.getCanHide()
              )
              .map((column) => {
                return (
                  <DropdownMenuCheckboxItem
                    key={column.id}
                    className="capitalize"
                    checked={column.getIsVisible()}
                    onCheckedChange={(value) =>
                      column.toggleVisibility(!!value)
                    }
                  >
                    {column.id}
                  </DropdownMenuCheckboxItem>
                )
              })}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
      <div className="overflow-hidden rounded-md border">
        <Table>{ ... }</Table>
      </div>
    </div>
  )
}

This adds a dropdown menu that you can use to toggle column visibility.

Row Selection#

Next, we're going to add row selection to our table.

Update column definitions#

app/payments/columns.tsx
"use client"
 
import { createColumnHelper } from "@tanstack/react-table"
 
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
 
export const columns = columnHelper.columns([
  columnHelper.display({
    id: "select",
    header: ({ table }) => (
      <Checkbox
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={
          table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
        }
        onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
        aria-label="Select all"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(!!value)}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
  }),
])

Update <DataTable>#

app/payments/data-table.tsx
export function DataTable<TData extends RowData>({
  columns,
  data,
}: DataTableProps<TData>) {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  )
  const [columnVisibility, setColumnVisibility] =
    React.useState<ColumnVisibilityState>({})
  const [rowSelection, setRowSelection] = React.useState({})
 
  const table = useTable({
    features,
    data,
    columns,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    state: {
      sorting,
      columnFilters,
      columnVisibility,
      rowSelection,
    },
  })
 
  return (
    <div>
      <div className="overflow-hidden rounded-md border">
        <Table />
      </div>
    </div>
  )
}

This adds a checkbox to each row and a checkbox in the header to select all rows.

Show selected rows#

You can show the number of selected rows using the table.getFilteredSelectedRowModel() API.

<div className="flex-1 text-sm text-muted-foreground">
  {table.getFilteredSelectedRowModel().rows.length} of{" "}
  {table.getFilteredRowModel().rows.length} row(s) selected.
</div>

Reusable Components#

Here are some components you can use to build your data tables. This is from the Tasks demo, which shares its features object (and the matching TasksTableFeatures type) across every component via a data-table-features.ts module, the same pattern we set up in Set up Table Features.

Column header#

Make any column header sortable and hideable.

components/data-table-column-header.tsx
import { type Column, type RowData } from "@tanstack/react-table"
import { cn } from "cn"
import { ArrowDownIcon, ArrowUpIcon, CaretUpDownIcon, EyeSlashIcon } from "@phosphor-icons/react"

import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

import { type TasksTableFeatures } from "./data-table-features"

interface DataTableColumnHeaderProps<TData extends RowData, TValue>
  extends React.HTMLAttributes<HTMLDivElement> {
  column: Column<TasksTableFeatures, TData, TValue>
  title: string
}

export function DataTableColumnHeader<TData extends RowData, TValue>({
  column,
  title,
  className,
}: DataTableColumnHeaderProps<TData, TValue>) {
  if (!column.getCanSort()) {
    return <div className={cn(className)}>{title}</div>
  }

  return (
    <div className={cn("flex items-center gap-2", className)}>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button
            variant="ghost"
            size="sm"
            className="-ml-3 h-8 data-[state=open]:bg-accent"
          >
            <span>{title}</span>
            {column.getIsSorted() === "desc" ? (
              <ArrowDownIcon weight="bold" />
            ) : column.getIsSorted() === "asc" ? (
              <ArrowUpIcon weight="bold" />
            ) : (
              <CaretUpDownIcon weight="bold" />
            )}
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start">
          <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
            <ArrowUpIcon weight="bold" />
            Asc
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
            <ArrowDownIcon weight="bold" />
            Desc
          </DropdownMenuItem>
          <DropdownMenuSeparator />
          <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
            <EyeSlashIcon weight="duotone" />
            Hide
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  )
}
export const columns = columnHelper.columns([
  columnHelper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
])

Pagination#

Add pagination controls to your table including page size and selection count.

import { type ReactTable, type RowData } from "@tanstack/react-table"
import { CaretDoubleLeftIcon, CaretDoubleRightIcon, CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react"

import { Button } from "@/components/ui/button"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

import { type TasksTableFeatures } from "./data-table-features"

interface DataTablePaginationProps<TData extends RowData> {
  table: ReactTable<TasksTableFeatures, TData>
}

export function DataTablePagination<TData extends RowData>({
  table,
}: DataTablePaginationProps<TData>) {
  return (
    <div className="flex items-center justify-between px-2">
      <div className="flex-1 text-sm text-muted-foreground">
        {table.getFilteredSelectedRowModel().rows.length} of{" "}
        {table.getFilteredRowModel().rows.length} row(s) selected.
      </div>
      <div className="flex items-center space-x-6 lg:space-x-8">
        <div className="flex items-center space-x-2">
          <p className="text-sm font-medium">Rows per page</p>
          <Select
            value={`${table.state.pagination.pageSize}`}
            onValueChange={(value) => {
              table.setPageSize(Number(value))
            }}
          >
            <SelectTrigger className="h-8 w-[70px]">
              <SelectValue placeholder={table.state.pagination.pageSize} />
            </SelectTrigger>
            <SelectContent side="top">
              {[10, 20, 25, 30, 40, 50].map((pageSize) => (
                <SelectItem key={pageSize} value={`${pageSize}`}>
                  {pageSize}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="flex w-[100px] items-center justify-center text-sm font-medium">
          Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}
        </div>
        <div className="flex items-center space-x-2">
          <Button
            variant="outline"
            size="icon"
            className="hidden size-8 lg:flex"
            onClick={() => table.setPageIndex(0)}
            disabled={!table.getCanPreviousPage()}
          >
            <span className="sr-only">Go to first page</span>
            <CaretDoubleLeftIcon weight="bold" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            <span className="sr-only">Go to previous page</span>
            <CaretLeftIcon weight="bold" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to next page</span>
            <CaretRightIcon weight="bold" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="hidden size-8 lg:flex"
            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to last page</span>
            <CaretDoubleRightIcon weight="bold" />
          </Button>
        </div>
      </div>
    </div>
  )
}
<DataTablePagination table={table} />

Column toggle#

A component to toggle column visibility.

"use client"

import { type ReactTable, type RowData } from "@tanstack/react-table"
import { GearIcon } from "@phosphor-icons/react"

import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

import { type TasksTableFeatures } from "./data-table-features"

export function DataTableViewOptions<TData extends RowData>({
  table,
}: {
  table: ReactTable<TasksTableFeatures, TData>
}) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          variant="outline"
          size="sm"
          className="ml-auto hidden h-8 lg:flex"
        >
          <GearIcon weight="duotone" />
          View
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-[150px]">
        <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {table
          .getAllColumns()
          .filter(
            (column) =>
              typeof column.accessorFn !== "undefined" && column.getCanHide()
          )
          .map((column) => {
            return (
              <DropdownMenuCheckboxItem
                key={column.id}
                className="capitalize"
                checked={column.getIsVisible()}
                onCheckedChange={(value) => column.toggleVisibility(!!value)}
              >
                {column.id}
              </DropdownMenuCheckboxItem>
            )
          })}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
<DataTableViewOptions table={table} />

RTL#

To enable RTL support, see the RTL configuration guide.

الحالة
المبلغ
ناجح
ken99@example.com
‏٣١٦٫٠٠ US$
ناجح
Abe45@example.com
‏٢٤٢٫٠٠ US$
قيد المعالجة
Monserrat44@example.com
‏٨٣٧٫٠٠ US$
ناجح
Silas22@example.com
‏٨٧٤٫٠٠ US$
فشل
carmella@example.com
‏٧٢١٫٠٠ US$
0 من 5 صف(وف) محدد.
Data GridDate Picker

On This Page

InstallationUsageColumn metaPropsPartsMobileBuild your ownIntroductionTable of ContentsInstallationPrerequisitesProject StructureSet up Table FeaturesBasic TableColumn Definitions<DataTable /> componentRender the tableCell FormattingUpdate columns definitionRow ActionsUpdate columns definitionPaginationPagination is already enabledAdd pagination controlsSortingUpdate <DataTable>Make header cell sortableFilteringUpdate <DataTable>VisibilityUpdate <DataTable>Row SelectionUpdate column definitionsUpdate <DataTable>Show selected rowsReusable ComponentsColumn headerPaginationColumn toggleRTL