# Data Table

> Data table with search, column sorting, row selection, pagination and custom cells.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/data-table
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/data-table.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/data-table-demo.tsx"
"use client"

import * as React from "react"
import { Download } from "lucide-react"

import { DataTable, type DataTableColumn } from "@/components/data-table"
import { cn } from "@/lib/utils"

type Member = {
  id: string
  name: string
  email: string
  role: "Owner" | "Admin" | "Member" | "Viewer"
  status: "Active" | "Invited" | "Suspended"
  seats: number
  lastActive: string
}

const members: Member[] = [
  { id: "m1", name: "Ada Lovelace", email: "ada@hexa.dev", role: "Owner", status: "Active", seats: 12, lastActive: "2026-11-06" },
  { id: "m2", name: "Alan Turing", email: "alan@hexa.dev", role: "Admin", status: "Active", seats: 8, lastActive: "2026-11-05" },
  { id: "m3", name: "Grace Hopper", email: "grace@hexa.dev", role: "Admin", status: "Invited", seats: 4, lastActive: "2026-10-28" },
  { id: "m4", name: "Linus Torvalds", email: "linus@hexa.dev", role: "Member", status: "Active", seats: 2, lastActive: "2026-11-06" },
  { id: "m5", name: "Margaret Hamilton", email: "margaret@hexa.dev", role: "Member", status: "Active", seats: 6, lastActive: "2026-11-02" },
  { id: "m6", name: "Dennis Ritchie", email: "dennis@hexa.dev", role: "Viewer", status: "Suspended", seats: 1, lastActive: "2026-09-14" },
  { id: "m7", name: "Barbara Liskov", email: "barbara@hexa.dev", role: "Member", status: "Active", seats: 3, lastActive: "2026-11-04" },
  { id: "m8", name: "Ken Thompson", email: "ken@hexa.dev", role: "Viewer", status: "Invited", seats: 1, lastActive: "2026-10-30" },
  { id: "m9", name: "Radia Perlman", email: "radia@hexa.dev", role: "Member", status: "Active", seats: 5, lastActive: "2026-11-01" },
  { id: "m10", name: "John Carmack", email: "john@hexa.dev", role: "Admin", status: "Active", seats: 9, lastActive: "2026-11-06" },
  { id: "m11", name: "Frances Allen", email: "frances@hexa.dev", role: "Viewer", status: "Active", seats: 1, lastActive: "2026-10-21" },
  { id: "m12", name: "Bjarne Stroustrup", email: "bjarne@hexa.dev", role: "Member", status: "Suspended", seats: 2, lastActive: "2026-08-30" },
]

const statusStyle: Record<Member["status"], string> = {
  Active: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
  Invited: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
  Suspended: "bg-red-500/10 text-red-600 dark:text-red-400",
}

const columns: DataTableColumn<Member>[] = [
  {
    key: "name",
    header: "Member",
    sortable: true,
    cell: (row) => (
      <div className="flex items-center gap-3">
        <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
          {row.name
            .split(" ")
            .map((part) => part[0])
            .join("")}
        </span>
        <div className="min-w-0">
          <p className="truncate font-medium">{row.name}</p>
          <p className="truncate text-xs text-muted-foreground">{row.email}</p>
        </div>
      </div>
    ),
  },
  { key: "role", header: "Role", sortable: true },
  {
    key: "status",
    header: "Status",
    sortable: true,
    cell: (row) => (
      <span className={cn("inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium", statusStyle[row.status])}>
        <span className="size-1.5 rounded-full bg-current" />
        {row.status}
      </span>
    ),
  },
  { key: "seats", header: "Seats", sortable: true, align: "right" },
  { key: "lastActive", header: "Last active", sortable: true, className: "text-muted-foreground whitespace-nowrap" },
]

const getRowId = (row: Member) => row.id

export default function DataTableDemo() {
  const [selection, setSelection] = React.useState<string[]>([])

  return (
    <div className="w-full max-w-4xl">
      <DataTable
        data={members}
        columns={columns}
        getRowId={getRowId}
        onSelectionChange={setSelection}
        searchPlaceholder="Search members…"
        toolbar={
          <button
            type="button"
            className="inline-flex h-9 items-center gap-2 rounded-lg border bg-background px-3 text-sm font-medium hover:bg-accent"
          >
            <Download className="size-4" />
            Export{selection.length > 0 && ` (${selection.length})`}
          </button>
        }
      />
    </div>
  )
}
```

## Source

```tsx title="components/data-table.tsx"
"use client"

import * as React from "react"
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Search } from "lucide-react"

import { cn } from "@/lib/utils"

export interface DataTableColumn<T> {
  key: keyof T & string
  header: string
  sortable?: boolean
  align?: "left" | "right"
  /** Custom cell renderer; defaults to the raw value */
  cell?: (row: T) => React.ReactNode
  className?: string
}

/** Presentation options, independent of the row type */
export interface DataTableProps extends Omit<React.ComponentProps<"div">, "children"> {
  searchable?: boolean
  searchPlaceholder?: string
  selectable?: boolean
  /** Rows per page; 0 disables pagination */
  pageSize?: number
  striped?: boolean
  dense?: boolean
  emptyText?: string
  /** Rendered next to the search box, e.g. bulk actions */
  toolbar?: React.ReactNode
}

export interface DataTableData<T> {
  data: T[]
  columns: DataTableColumn<T>[]
  getRowId?: (row: T, index: number) => string
  onSelectionChange?: (ids: string[]) => void
}

type Sort = { key: string; direction: "asc" | "desc" } | null

const indexId = (_row: unknown, index: number) => String(index)

function compare(a: unknown, b: unknown) {
  if (typeof a === "number" && typeof b === "number") return a - b
  return String(a ?? "").localeCompare(String(b ?? ""), undefined, { numeric: true, sensitivity: "base" })
}

function Checkbox({ checked, indeterminate, ...props }: React.ComponentProps<"input"> & { indeterminate?: boolean }) {
  return (
    <input
      type="checkbox"
      checked={checked}
      ref={(el) => {
        if (el) el.indeterminate = Boolean(indeterminate)
      }}
      className="size-4 cursor-pointer rounded accent-primary"
      {...props}
    />
  )
}

export function DataTable<T extends object>({
  data,
  columns,
  getRowId = indexId,
  onSelectionChange,
  searchable = true,
  searchPlaceholder = "Search…",
  selectable = true,
  pageSize = 5,
  striped = false,
  dense = false,
  emptyText = "No results.",
  toolbar,
  className,
  ...props
}: DataTableProps & DataTableData<T>) {
  const [query, setQuery] = React.useState("")
  const [sort, setSort] = React.useState<Sort>(null)
  const [page, setPage] = React.useState(0)
  const [selected, setSelected] = React.useState<Set<string>>(() => new Set())

  const rows = React.useMemo(() => {
    const value = (row: T, key: string) => (row as Record<string, unknown>)[key]
    let result = data.map((row, index) => ({ row, id: getRowId(row, index) }))
    const needle = query.trim().toLowerCase()
    if (needle)
      result = result.filter(({ row }) => columns.some((column) => String(value(row, column.key) ?? "").toLowerCase().includes(needle)))
    if (sort) {
      const factor = sort.direction === "asc" ? 1 : -1
      result = [...result].sort((a, b) => compare(value(a.row, sort.key), value(b.row, sort.key)) * factor)
    }
    return result
  }, [data, columns, getRowId, query, sort])

  const pageCount = pageSize > 0 ? Math.max(1, Math.ceil(rows.length / pageSize)) : 1
  const currentPage = Math.min(page, pageCount - 1)
  const visible = pageSize > 0 ? rows.slice(currentPage * pageSize, (currentPage + 1) * pageSize) : rows

  const updateSelection = (next: Set<string>) => {
    setSelected(next)
    onSelectionChange?.([...next])
  }
  const allVisibleSelected = visible.length > 0 && visible.every(({ id }) => selected.has(id))
  const someVisibleSelected = visible.some(({ id }) => selected.has(id))

  const toggleSort = (key: string) =>
    setSort((current) =>
      current?.key !== key ? { key, direction: "asc" } : current.direction === "asc" ? { key, direction: "desc" } : null
    )

  const cellPadding = dense ? "px-3 py-2" : "px-4 py-3"

  return (
    <div className={cn("flex w-full flex-col gap-3", className)} {...props}>
      {(searchable || toolbar || (selectable && selected.size > 0)) && (
        <div className="flex flex-wrap items-center gap-3">
          {searchable && (
            <label className="relative flex-1 sm:max-w-64">
              <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
              <input
                type="search"
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value)
                  setPage(0)
                }}
                placeholder={searchPlaceholder}
                aria-label="Search rows"
                className="h-9 w-full rounded-lg border bg-background pr-3 pl-9 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
              />
            </label>
          )}
          {selectable && selected.size > 0 && (
            <span className="text-sm text-muted-foreground animate-in fade-in">
              {selected.size} selected ·{" "}
              <button type="button" onClick={() => updateSelection(new Set())} className="font-medium text-foreground hover:underline">
                Clear
              </button>
            </span>
          )}
          {toolbar && <div className="ml-auto flex items-center gap-2">{toolbar}</div>}
        </div>
      )}

      <div className="overflow-x-auto rounded-xl border">
        <table className="w-full border-collapse text-sm">
          <thead className="bg-muted/50 text-left text-xs font-medium text-muted-foreground">
            <tr>
              {selectable && (
                <th scope="col" className={cn(cellPadding, "w-10")}>
                  <Checkbox
                    aria-label="Select all rows on this page"
                    checked={allVisibleSelected}
                    indeterminate={!allVisibleSelected && someVisibleSelected}
                    onChange={() => {
                      const next = new Set(selected)
                      for (const { id } of visible) {
                        if (allVisibleSelected) next.delete(id)
                        else next.add(id)
                      }
                      updateSelection(next)
                    }}
                  />
                </th>
              )}
              {columns.map((column) => {
                const active = sort?.key === column.key ? sort.direction : null
                const Icon = active === "asc" ? ArrowUp : active === "desc" ? ArrowDown : ArrowUpDown
                return (
                  <th
                    key={column.key}
                    scope="col"
                    aria-sort={active === "asc" ? "ascending" : active === "desc" ? "descending" : undefined}
                    className={cn(cellPadding, "font-medium whitespace-nowrap", column.align === "right" && "text-right")}
                  >
                    {column.sortable ? (
                      <button
                        type="button"
                        onClick={() => toggleSort(column.key)}
                        className={cn(
                          "-mx-1.5 inline-flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors hover:bg-accent hover:text-foreground",
                          active && "text-foreground"
                        )}
                      >
                        {column.header}
                        <Icon className={cn("size-3.5", !active && "opacity-40")} />
                      </button>
                    ) : (
                      column.header
                    )}
                  </th>
                )
              })}
            </tr>
          </thead>
          <tbody>
            {visible.length === 0 ? (
              <tr>
                <td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-10 text-center text-muted-foreground">
                  {emptyText}
                </td>
              </tr>
            ) : (
              visible.map(({ row, id }) => {
                const isSelected = selected.has(id)
                return (
                  <tr
                    key={id}
                    data-selected={isSelected || undefined}
                    className={cn(
                      "border-t transition-colors hover:bg-muted/40 data-selected:bg-primary/5",
                      striped && "even:bg-muted/25"
                    )}
                  >
                    {selectable && (
                      <td className={cellPadding}>
                        <Checkbox
                          aria-label="Select row"
                          checked={isSelected}
                          onChange={() => {
                            const next = new Set(selected)
                            if (isSelected) next.delete(id)
                            else next.add(id)
                            updateSelection(next)
                          }}
                        />
                      </td>
                    )}
                    {columns.map((column) => (
                      <td
                        key={column.key}
                        className={cn(cellPadding, column.align === "right" && "text-right tabular-nums", column.className)}
                      >
                        {column.cell ? column.cell(row) : String((row as Record<string, unknown>)[column.key] ?? "")}
                      </td>
                    ))}
                  </tr>
                )
              })
            )}
          </tbody>
        </table>
      </div>

      {pageSize > 0 && (
        <div className="flex items-center justify-between text-sm text-muted-foreground">
          <span className="tabular-nums">
            {rows.length === 0
              ? "0 rows"
              : `${currentPage * pageSize + 1}–${Math.min((currentPage + 1) * pageSize, rows.length)} of ${rows.length}`}
          </span>
          <div className="flex items-center gap-2">
            <span className="tabular-nums">
              Page {currentPage + 1} / {pageCount}
            </span>
            <button
              type="button"
              aria-label="Previous page"
              onClick={() => setPage(currentPage - 1)}
              disabled={currentPage === 0}
              className="flex size-8 items-center justify-center rounded-lg border bg-background hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
            >
              <ChevronLeft className="size-4" />
            </button>
            <button
              type="button"
              aria-label="Next page"
              onClick={() => setPage(currentPage + 1)}
              disabled={currentPage >= pageCount - 1}
              className="flex size-8 items-center justify-center rounded-lg border bg-background hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
            >
              <ChevronRight className="size-4" />
            </button>
          </div>
        </div>
      )}
    </div>
  )
}
```
