Uqudo UIregistry

Data Table

Sortable, filterable, paginated table with column visibility, row selection, clickable rows and empty states — built on TanStack Table v8.

#
1Amina Khanuser1@uqudo.comactive
2Omar Haddaduser2@uqudo.cominvited
3Lina Salehuser3@uqudo.comsuspended
4Yusuf Rahmanuser4@uqudo.comactive
5Amina Khanuser5@uqudo.cominvited
6Omar Haddaduser6@uqudo.comsuspended
7Lina Salehuser7@uqudo.comactive
8Yusuf Rahmanuser8@uqudo.cominvited
9Amina Khanuser9@uqudo.comsuspended
10Omar Haddaduser10@uqudo.comactive

Rows per page

Page 1 of 3

DataTable is a block: one component that wires TanStack Table to the Table primitive and adds the toolbar, filters, pagination and empty states every Uqudo list screen needs. You describe the columns, hand it the rows, and switch features on or off with props. Everything is client-side — sorting, filtering and paging happen over the data array you pass in.

datatable.tsx is the entry point and the only file you import from for the table itself. The other files are the pieces it composes; two of them (DataTableColumnHeader, RowNumberCell) are meant to be used inside your column definitions, and DataTableEmptyState inside the emptyState prop.

datatable.tsx
datatable-column-header.tsx
datatable-empty-state.tsx
datatable-multi-select.tsx
datatable-pagination.tsx
datatable-search-by.tsx
datatable-view-options.tsx
row-number-cell.tsx
data-table.json

Installation

npx shadcn@latest add @uqudo/data-table

The block targets TanStack Table v8 (useReactTable, ColumnDef<TData, TValue>). The CLI installs @tanstack/react-table@^8; if your project already has v9 the two APIs are incompatible and the types will fail.

Usage

import type { ColumnDef } from "@tanstack/react-table"

import { DataTable } from "@/components/uqudo/blocks/data-table/datatable"
import { DataTableColumnHeader } from "@/components/uqudo/blocks/data-table/datatable-column-header"

Define the columns once, outside the component so their identity is stable:

type User = { id: string; name: string; email: string; status: string }

const columns: ColumnDef<User>[] = [
  {
    accessorKey: "name",
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Name" />
    ),
  },
  { accessorKey: "email", header: "Email" },
  { accessorKey: "status", header: "Status" },
]

Then render:

<DataTable
  columns={columns}
  data={users}
  searchByCol="email"
  filters={[{ column: "status", options: ["active", "invited"] }]}
/>

DataTable is a client component. Column definitions contain render functions, so the file that declares them must also be "use client" (or the columns must be built inside a client component).

How it works

Layout

The component renders three regions from top to bottom:

  1. Toolbar — search input, filter dropdowns, the "View" column toggle and your actionButton.
  2. Table — inside an overflow-x-auto wrapper with min-w-[600px], so narrow screens scroll horizontally rather than squashing cells.
  3. Pagination — page size select and first/prev/next/last controls.

Each region has its own switch, and the toolbar parts have their own conditions on top:

PartRendered when
Search inputsearchInput and searchByCol are set
Filter dropdownsfilters has at least one entry
"View" menuviewOption (default true)
actionButtonProvided
PaginationwithPagination (default true)

Turning everything off leaves just the table:

CodeCountryRegion
AEUnited Arab EmiratesMiddle East
SASaudi ArabiaMiddle East
EGEgyptAfrica
GBUnited KingdomEurope

Two toolbars

There are two toolbar markups, toggled with Tailwind's sm breakpoint. On desktop (sm and up) the search, filters and view menu sit in one row. Below sm the search collapses to a round icon button; tapping it swaps the whole toolbar for a full-width search field with a close button, and focus is moved into the input. Filters and the view menu become round icon buttons too. The same table instance drives both, so state never gets out of sync when the viewport changes.

searchByCol names the column the input filters on — a single column, not a global search. The value is written with column.setFilterValue, which means TanStack's automatic filter applies: a case-insensitive substring match for string columns. The column must exist in columns (by accessorKey or id), otherwise the input does nothing.

Filters

Each entry in filters becomes a dashed dropdown button listing options as checkboxes. Checked values are stored as an array in the column's filter value; clearing the last one removes the filter entirely. defaultValue pre-checks one option on first render — useful for "show open items by default".

IDSubjectPriorityState
T-1Login loops on Safarihighopen
T-2Export CSV is emptymediumopen
T-5Dark mode contrastlowopen

Columns referenced by filters automatically get an exact-match multi-select filter function. TanStack's default for string columns is a substring test against the stringified filter value, so two checked options ("active,pending") would otherwise match nothing. If a column declares its own filterFn, that one is kept.

The trigger shows the checked values as chips. Labels fall back to the column id, so pass label for anything that isn't already a readable word.

Sorting and the column header

Sorting is opt-in per column: use DataTableColumnHeader as the column's header and you get a ghost button that opens Asc / Desc / Hide. Set enableSorting: false on the column and the same component renders a plain label at matching size, so mixed headers still line up. Use enableHiding: false to keep a column out of the "Hide" action and the view menu.

The header also stores its title as column.columnDef.meta.label. That is where the view menu reads names from, so a column using this header shows as "Created at" in the menu rather than createdAt.

Column visibility

The "View" menu lists every column that has an accessor and can hide — so select, actions and other display-only columns are excluded, as are columns with the ids id and hiddenColumn. The label is meta.label, then a string header, then the column id.

Start columns hidden with initialColumnVisibility; the user can still turn them on:

<DataTable columns={columns} data={rows} initialColumnVisibility={{ role: false }} />

Row selection

Selection state lives inside the component, seeded from rowSelection on first render, and onRowSelectionChange reports every change. Keys are TanStack row ids — the row's index in data unless you pass getRowId through a custom column setup — so map them back to your records yourself. The table only tracks state; you render the checkboxes in a select column:

Nothing selected

Invoice
INV-001Acme Corp$1,250
INV-002Globex$480
INV-003Initech$3,200
INV-004Umbrella$990

Selected rows receive data-state="selected", which the Table primitive styles.

rowSelection is read once. Changing the prop later does not reset the selection — remount the table (for example with a key) if you need that.

Clickable rows

Pass onRowClick and each row gets role="button", a pointer cursor, tabIndex={0} and Enter / Space handling. Clicks are ignored when they start inside a button, a, input, select, textarea, [role="menuitem"] or anything marked data-no-row-click, so an actions column keeps working without also opening the row. Omit the prop and rows stay inert.

Click a row, or the delete button.

TitleOwner
Q3 compliance reportAmina
Onboarding checklistOmar
Vendor contractLina

Empty states

Two different situations are told apart:

  • data is empty → your emptyState if provided, else a generic "Nothing here yet".
  • data has rows but the search/filters match none → always the generic "No matching results", because the fix is to change the filters, not to create data.

Use DataTableEmptyState for a tailored first case — it takes an icon, a description and an action slot:

ProjectOwner

No projects yet

Projects you create will show up here.

Pagination and row numbers

Pagination is client-side with a default page size of 10 and choices of 10 to 50. RowNumberCell renders the row's 1-based position across pages (pageIndex * pageSize + index + 1), so numbering continues on page 2. Put it in a display column with enableSorting: false and enableHiding: false; it positions itself absolutely inside the cell:

{
  id: "index",
  header: "#",
  enableSorting: false,
  enableHiding: false,
  cell: ({ row, table }) => <RowNumberCell row={row} table={table} />,
}

getRowNumber(row, table) is exported for when you need the number without the markup.

API reference

DataTable

Prop

Type

DataTableFilter

Prop

Type

DataTableColumnHeader

Use as a column's header. Extends HTMLAttributes<HTMLDivElement>.

Prop

Type

RowNumberCell

Prop

Type

DataTableEmptyState

Prop

Type

Source

components/uqudo/blocks/data-table/datatable.tsx
"use client"

import * as React from "react"
import {
  type ColumnDef,
  type ColumnFiltersState,
  type FilterFn,
  type RowData,
  type SortingState,
  type VisibilityState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table"
import { Search, X } from "lucide-react"

import { Button } from "@/components/uqudo/ui/button"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/uqudo/ui/table"

import { DataTableEmptyState } from "./datatable-empty-state"
import { MultiSelectFilters } from "./datatable-multi-select"
import { DataTablePagination } from "./datatable-pagination"
import { SearchBy } from "./datatable-search-by"
import { DataTableViewOptions } from "./datatable-view-options"

declare module "@tanstack/react-table" {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  interface ColumnMeta<TData extends RowData, TValue> {
    /** Human-readable name shown in the view-options menu. Set by DataTableColumnHeader. */
    label?: string
  }
}

export type DataTableFilter<TData> = {
  /** Column id (the accessor key) the filter applies to. */
  column: keyof TData
  /** Trigger label. Defaults to the column id. */
  label?: string
  /** Values offered in the dropdown. Rows match when their cell equals one of the checked values. */
  options?: string[]
  /** Pre-checked value on first render. */
  defaultValue?: string
}

export interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
  /**
   * Column the search input filters on. Without it no search input is rendered,
   * even when `searchInput` is true.
   */
  searchByCol?: keyof TData
  rowSelection?: Record<string, boolean>
  onRowSelectionChange?: (value: Record<string, boolean>) => void
  withPagination?: boolean
  viewOption?: boolean
  searchInput?: boolean
  filters?: DataTableFilter<TData>[]
  /** Rendered at the end of the toolbar, e.g. a "Create" button. */
  actionButton?: React.ReactNode
  /**
   * Columns to start hidden, keyed by column id, e.g. `{ caseId: false }`. The
   * user can still turn them back on from the view options menu, so use this for
   * detail that's useful on demand rather than by default.
   */
  initialColumnVisibility?: VisibilityState
  /**
   * Rendered when the dataset is genuinely empty (no rows at all). Pass a
   * tailored <DataTableEmptyState/> here. When the dataset has rows but the
   * active search/filters match nothing, a generic "no matching results" state
   * is shown instead. Falls back to a generic empty state when omitted.
   */
  emptyState?: React.ReactNode
  /**
   * When provided, the whole row becomes clickable (pointer cursor, hover,
   * keyboard-activatable) and this fires with the row's original data. Clicks
   * that originate from an interactive child (button, link, input, menu item,
   * or anything marked `data-no-row-click`) are ignored so the actions column
   * keeps working. Opt-in per table — omit it to keep rows non-interactive.
   */
  onRowClick?: (row: TData) => void
}

const INTERACTIVE_CHILD_SELECTOR =
  'button, a, input, select, textarea, [role="menuitem"], [data-no-row-click]'

/**
 * Exact-match against any of the checked values. TanStack's automatic filter
 * for string columns is a substring test on the stringified filter value, so
 * `["active", "pending"]` would become `"active,pending"` and match nothing.
 */
const multiSelectFilterFn: FilterFn<unknown> = (row, columnId, values) => {
  const selected = values as string[]
  return selected.includes(String(row.getValue(columnId)))
}
multiSelectFilterFn.autoRemove = (value: unknown) =>
  !Array.isArray(value) || value.length === 0

/** Gives every column that has a dropdown filter the multi-select filter, unless it brings its own. */
function withFilterFns<TData, TValue>(
  columns: ColumnDef<TData, TValue>[],
  filters: DataTableFilter<TData>[] | undefined
) {
  if (!filters?.length) return columns

  const filtered = new Set(filters.map((filter) => String(filter.column)))

  return columns.map((column) => {
    const id =
      column.id ??
      ("accessorKey" in column ? String(column.accessorKey) : undefined)

    if (!id || !filtered.has(id) || column.filterFn) return column

    return { ...column, filterFn: multiSelectFilterFn as FilterFn<TData> }
  })
}

export function DataTable<TData, TValue>({
  columns,
  data,
  searchByCol,
  filters,
  withPagination = true,
  viewOption = true,
  searchInput = true,
  rowSelection,
  onRowSelectionChange,
  actionButton,
  initialColumnVisibility,
  emptyState,
  onRowClick,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    () =>
      filters?.reduce<ColumnFiltersState>((acc, filter) => {
        if (filter.defaultValue) {
          acc.push({ id: String(filter.column), value: [filter.defaultValue] })
        }
        return acc
      }, []) ?? []
  )
  const [columnVisibility, setColumnVisibility] =
    React.useState<VisibilityState>(initialColumnVisibility ?? {})
  const [rowSelectionState, setRowSelectionState] = React.useState<
    Record<string, boolean>
  >(rowSelection ?? {})
  const [isMobileSearchOpen, setIsMobileSearchOpen] = React.useState(false)
  const mobileSearchRef = React.useRef<HTMLInputElement>(null)

  const openMobileSearch = () => {
    setIsMobileSearchOpen(true)
    requestAnimationFrame(() => mobileSearchRef.current?.focus())
  }

  const closeMobileSearch = () => {
    setIsMobileSearchOpen(false)
    requestAnimationFrame(() => mobileSearchRef.current?.blur())
  }

  const resolvedColumns = React.useMemo(
    () => withFilterFns(columns, filters),
    [columns, filters]
  )

  const table = useReactTable({
    data,
    columns: resolvedColumns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    onSortingChange: setSorting,
    getSortedRowModel: getSortedRowModel(),
    onColumnFiltersChange: setColumnFilters,
    getFilteredRowModel: getFilteredRowModel(),
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: (updater) => {
      const next =
        typeof updater === "function" ? updater(rowSelectionState) : updater
      setRowSelectionState(next)
      onRowSelectionChange?.(next)
    },
    state: {
      sorting,
      columnFilters,
      columnVisibility,
      rowSelection: rowSelectionState,
    },
  })

  const rows = table.getRowModel().rows
  const hasFilters = Boolean(filters && filters.length > 0)

  return (
    <div className="w-full max-w-full">
      {/* Mobile toolbar: icon buttons, search expands in place. */}
      <div className="mb-4 space-y-2 sm:hidden">
        {isMobileSearchOpen && searchInput ? (
          <div className="flex items-center gap-2">
            <SearchBy
              table={table}
              searchByCol={searchByCol}
              wrapperClassName="flex-1"
              inputClassName="h-12 rounded-xl"
              inputRef={mobileSearchRef}
            />
            <Button
              variant="ghost"
              size="icon"
              className="size-10 rounded-full"
              onClick={closeMobileSearch}
              aria-label="Close search"
            >
              <X className="size-5" />
            </Button>
          </div>
        ) : (
          <div className="flex items-center justify-between gap-4">
            <div className="flex items-center gap-2">
              {searchInput && searchByCol && (
                <Button
                  variant="outline"
                  size="icon"
                  className="size-10 rounded-full"
                  onClick={openMobileSearch}
                  aria-label="Search"
                >
                  <Search className="size-4" />
                </Button>
              )}
              {hasFilters && (
                <MultiSelectFilters
                  filters={filters!}
                  table={table}
                  triggerClassName="rounded-full px-4 py-2"
                />
              )}
            </div>
            <div className="flex items-center gap-2">
              {viewOption && (
                <DataTableViewOptions
                  table={table}
                  hideLabel
                  className="size-10 rounded-full"
                />
              )}
              {actionButton}
            </div>
          </div>
        )}
      </div>

      {/* Desktop toolbar. */}
      <div className="mb-4 hidden flex-col gap-y-4 sm:flex sm:flex-row sm:items-center sm:justify-between">
        <div className="flex flex-1 flex-col gap-y-2 sm:flex-row sm:items-center sm:gap-x-4">
          {searchInput && searchByCol && (
            <SearchBy table={table} searchByCol={searchByCol} />
          )}
          {hasFilters && (
            <MultiSelectFilters filters={filters!} table={table} />
          )}
        </div>
        <div className="flex items-center gap-x-4">
          {viewOption && <DataTableViewOptions table={table} />}
          {actionButton}
        </div>
      </div>

      <div className="mb-4 overflow-x-auto rounded-md border">
        <Table className="w-full min-w-[600px]">
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id} className="px-4 py-2 text-sm">
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {rows.length ? (
              rows.map((row) => (
                <TableRow
                  key={row.id}
                  data-state={row.getIsSelected() && "selected"}
                  className={onRowClick ? "cursor-pointer" : undefined}
                  role={onRowClick ? "button" : undefined}
                  tabIndex={onRowClick ? 0 : undefined}
                  onClick={
                    onRowClick
                      ? (event) => {
                          if (
                            (event.target as HTMLElement).closest(
                              INTERACTIVE_CHILD_SELECTOR
                            )
                          ) {
                            return
                          }
                          onRowClick(row.original)
                        }
                      : undefined
                  }
                  onKeyDown={
                    onRowClick
                      ? (event) => {
                          if (event.key === "Enter" || event.key === " ") {
                            event.preventDefault()
                            onRowClick(row.original)
                          }
                        }
                      : undefined
                  }
                >
                  {row.getVisibleCells().map((cell) => (
                    <TableCell
                      key={cell.id}
                      className="relative px-4 py-2 text-sm"
                    >
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow className="hover:bg-transparent">
                <TableCell colSpan={columns.length} className="p-0">
                  {data.length === 0 ? (
                    (emptyState ?? (
                      <DataTableEmptyState
                        compact
                        title="Nothing here yet"
                        description="There's no data to display right now."
                      />
                    ))
                  ) : (
                    <DataTableEmptyState
                      compact
                      title="No matching results"
                      description="Try adjusting your search or filters to find what you're looking for."
                    />
                  )}
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      {withPagination && <DataTablePagination table={table} />}
    </div>
  )
}

On this page