# Data Table
The brand's table with a search field, sortable heads, row selection, a Columns menu and page keys.

Install: npx opencommerce-ds add data-table
Requires: theme, button, checkbox, dropdown-menu, input, pagination, select, table, utils
Packages: lucide-react
Exports: DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions, DataTableColumn, DataTableProps, SortState
Client boundary: yes
Reference: opencommerce-agentcard-web src/components/monitoring.tsx (the merchant table) with table.tsx and pagination.tsx


## Usage
Use for records people sort, filter, select and page through (orders, merchants, API keys). Describe each column with an id, a header, an accessor for sorting and filtering, and a cell to render it; set sortable on the columns that can be sorted. Set selectable for a checkbox column and pageSizes to offer rows per page. Put bulk actions in toolbar.
- Avoid: A data table for fewer than ten rows (use the table)
- Avoid: Animating rows when sorting or paging
- Avoid: Hiding the only identifying column


## Examples
Example components: npx opencommerce-ds add data-table badge button
[Site](https://opencommerce-ds.vercel.app/preview/data-table/Site) | [Selection](https://opencommerce-ds.vercel.app/preview/data-table/Selection) | [Empty](https://opencommerce-ds.vercel.app/preview/data-table/Empty)

examples/data-table.tsx

```tsx
"use client";

import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataTable, type DataTableColumn } from "@/components/ui/data-table";

type Order = { id: string; merchant: string; agent: string; total: number; status: "Delivered" | "Shipped" | "Placed" | "Failed"; placed: string };

const MERCHANTS = ["Northwind Outfitters", "Contoso Coffee", "Fabrikam Home", "Tailspin Toys", "Litware Books"];
const AGENTS = ["travel-agent", "office-supplies", "gift-buyer"];
const STATUSES: Order["status"][] = ["Delivered", "Shipped", "Placed", "Delivered", "Failed", "Shipped"];

const ORDERS: Order[] = Array.from({ length: 36 }, (_, i) => ({
  id: `ord_${(8000 + i * 37).toString(36).toUpperCase()}`,
  merchant: MERCHANTS[(i * 3) % MERCHANTS.length],
  agent: AGENTS[i % AGENTS.length],
  total: Math.round(((i * 7919) % 48000) + 1200) / 100,
  status: STATUSES[i % STATUSES.length],
  placed: `2026-10-${String(26 - (i % 20)).padStart(2, "0")}`,
}));

const STATUS_BADGE = { Delivered: "success", Shipped: "brand", Placed: "default", Failed: "destructive" } as const;

const COLUMNS: DataTableColumn<Order>[] = [
  { id: "id", header: "Order", accessor: (o) => o.id, cell: (o) => <span className="font-mono text-snippet">{o.id}</span>, hideable: false },
  { id: "merchant", header: "Merchant", accessor: (o) => o.merchant, sortable: true },
  { id: "agent", header: "Agent", accessor: (o) => o.agent, cell: (o) => <span className="font-mono text-snippet text-fg-muted">{o.agent}</span> },
  { id: "status", header: "Status", accessor: (o) => o.status, sortable: true, cell: (o) => <Badge variant={STATUS_BADGE[o.status]}>{o.status}</Badge> },
  { id: "placed", header: "Placed", accessor: (o) => o.placed, sortable: true, hidden: true, cell: (o) => <span className="font-mono text-snippet">{o.placed}</span> },
  { id: "total", header: "Total", accessor: (o) => o.total, sortable: true, align: "end", cell: (o) => <span className="font-mono text-snippet">${o.total.toFixed(2)}</span> },
];

export function Site() {
  return <DataTable columns={COLUMNS} data={ORDERS} getRowId={(o) => o.id} filter={{ placeholder: "Filter orders" }} pageSize={8} pageSizes={[8, 16, 32]} defaultSort={{ id: "total", desc: true }} rowName="order" />;
}

export function Selection() {
  const [selection, setSelection] = React.useState<string[]>([]);
  const [message, setMessage] = React.useState("");
  return (
    <div className="grid gap-3">
      <DataTable
        columns={COLUMNS}
        data={ORDERS}
        getRowId={(o) => o.id}
        filter={{ placeholder: "Filter orders", columns: ["id", "merchant", "agent"] }}
        selectable
        selection={selection}
        onSelectionChange={setSelection}
        pageSize={6}
        rowName="order"
        toolbar={
          <Button
            variant="secondary"
            size="sm"
            disabled={!selection.length}
            onClick={() => {
              setMessage(`Retried ${selection.length} ${selection.length === 1 ? "checkout" : "checkouts"}.`);
              setSelection([]);
            }}
          >
            Retry selected
          </Button>
        }
      />
      <p role="status" className="text-body-sm text-fg-muted empty:hidden">
        {message}
      </p>
    </div>
  );
}

export function Empty() {
  return <DataTable columns={COLUMNS} data={[]} getRowId={(o) => o.id} filter={{ placeholder: "Filter orders" }} empty="No orders yet. Your agent's first checkout shows up here." rowName="order" />;
}

```

## Source

components/ui/data-table.tsx

```tsx
"use client";

import * as React from "react";
import { ArrowDown, ArrowUp, ChevronsUpDown, Columns3 } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";

// A table of records people sort, filter, select and page through, built on the brand's table (the live merchant table,
// monitoring.tsx): one white frame with 12px corners on the 16 percent ink edge and the window's drop, 12px heads at 64
// percent ink, 14px rows on 9 percent rules that take the mist under the pointer (a selected row the pale blue). A
// sortable head is a bare label with an arrow for its direction that turns ink under the pointer. Above the table, a
// toolbar: the 36px search field and the white Columns key that opens a menu of checkboxes; below it, the selection
// count, rows per page and the page keys. Sorting, filtering and paging are instant (no motion): they are repeated many
// times in a row. On a phone each row stacks under its column names, as the table does.

type Value = string | number | Date | boolean | null | undefined;

type DataTableColumn<T> = {
  id: string;
  /** The column's label, in its head and in the Columns menu. */
  header: React.ReactNode;
  /** What the cell shows; defaults to the accessor's value. */
  cell?: (row: T) => React.ReactNode;
  /** The value used to sort and filter. */
  accessor?: (row: T) => Value;
  sortable?: boolean;
  /** Whether the Columns menu can hide it (default true). */
  hideable?: boolean;
  /** Hidden until turned on in the Columns menu. */
  hidden?: boolean;
  align?: "start" | "end";
  className?: string;
};

type SortState = { id: string; desc: boolean } | null;

const text = (v: Value) => (v instanceof Date ? v.toISOString() : v == null ? "" : String(v));
function compare(a: Value, b: Value) {
  if (a == null && b == null) return 0;
  if (a == null) return 1;
  if (b == null) return -1;
  if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
  if (typeof a === "number" && typeof b === "number") return a - b;
  return text(a).localeCompare(text(b), undefined, { numeric: true, sensitivity: "base" });
}

/** A sortable head label: a ghost key whose arrow shows the direction (both carets while unsorted). */
function DataTableColumnHeader({ title, sorted, onSort, className }: { title: React.ReactNode; sorted: false | "asc" | "desc"; onSort?: () => void; className?: string }) {
  if (!onSort) return <span className={cn("inline-flex h-7 items-center", className)}>{title}</span>;
  const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ChevronsUpDown;
  return (
    <button
      type="button"
      data-slot="data-table-column-header"
      onClick={onSort}
      data-sorted={!!sorted}
      className={cn(
        "-mx-1.5 inline-flex h-7 items-center gap-1 rounded-menu px-1.5 font-sans text-caption leading-none font-normal text-fg-muted outline-none transition-[background-color,color] duration-150 ease-out hover:bg-wash-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-focus-ring data-[sorted=true]:text-fg motion-reduce:transition-none",
        className,
      )}
    >
      {title}
      <Icon aria-hidden strokeWidth={1.5} className={cn("size-3", !sorted && "opacity-60")} />
    </button>
  );
}

/** The Columns key and its menu of checkboxes, one per column that can be hidden. */
function DataTableViewOptions({ columns, visible, onVisibleChange }: { columns: { id: string; header: React.ReactNode }[]; visible: Record<string, boolean>; onVisibleChange: (id: string, on: boolean) => void }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="secondary" size="sm" className="shrink-0">
          <Columns3 strokeWidth={1.5} />
          Columns
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-52">
        <DropdownMenuLabel>Show columns</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {columns.map((c) => (
          <DropdownMenuCheckboxItem key={c.id} checked={visible[c.id] !== false} onCheckedChange={(on) => onVisibleChange(c.id, on === true)} onSelect={(e) => e.preventDefault()}>
            {c.header}
          </DropdownMenuCheckboxItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

/** The page numbers around the current one: the ends, its neighbors and ellipses between. */
function pageList(page: number, count: number): (number | "gap")[] {
  if (count <= 7) return Array.from({ length: count }, (_, i) => i);
  const set = new Set([0, count - 1, page - 1, page, page + 1].filter((p) => p >= 0 && p < count));
  const sorted = [...set].sort((a, b) => a - b);
  return sorted.flatMap((p, i) => (i && p - sorted[i - 1] > 1 ? (["gap", p] as const) : [p]));
}

/** The row under the table: the selection count, rows per page and the page keys. */
function DataTablePagination({ page, pageCount, onPageChange, pageSize, pageSizes, onPageSizeChange, selected, total, className }: { page: number; pageCount: number; onPageChange: (page: number) => void; pageSize?: number; pageSizes?: number[]; onPageSizeChange?: (size: number) => void; selected?: number; total?: number; className?: string }) {
  const go = (p: number) => (e: React.MouseEvent) => {
    e.preventDefault();
    if (p >= 0 && p < pageCount) onPageChange(p);
  };
  return (
    <Pagination className={cn("flex-wrap justify-between gap-x-4 gap-y-2", className)}>
      <div className="flex min-h-8.5 flex-wrap items-center gap-x-4 gap-y-1 text-body-sm leading-tight text-fg-muted">
        {selected !== undefined && total !== undefined && (
          <span aria-live="polite">
            {selected} of {total} selected
          </span>
        )}
        {pageSizes && pageSize && onPageSizeChange && (
          <span className="flex items-center gap-2">
            <span id="data-table-page-size">Rows per page</span>
            <Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
              <SelectTrigger compact aria-labelledby="data-table-page-size" className="w-18">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {pageSizes.map((s) => (
                  <SelectItem key={s} value={String(s)}>
                    {s}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </span>
        )}
      </div>
      <PaginationContent className="ml-auto">
        <PaginationItem>
          <PaginationPrevious href="#" onClick={go(page - 1)} aria-disabled={page === 0 || undefined} tabIndex={page === 0 ? -1 : undefined} />
        </PaginationItem>
        {pageList(page, pageCount).map((p, i) =>
          p === "gap" ? (
            <PaginationItem key={`gap-${i}`}>
              <PaginationEllipsis />
            </PaginationItem>
          ) : (
            <PaginationItem key={p}>
              <PaginationLink href="#" isActive={p === page} onClick={go(p)} aria-label={`Page ${p + 1}`}>
                {p + 1}
              </PaginationLink>
            </PaginationItem>
          ),
        )}
        <PaginationItem>
          <PaginationNext href="#" onClick={go(page + 1)} aria-disabled={page >= pageCount - 1 || undefined} tabIndex={page >= pageCount - 1 ? -1 : undefined} />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  );
}

type DataTableProps<T> = {
  columns: DataTableColumn<T>[];
  data: T[];
  getRowId: (row: T) => string;
  /** A filter field over these columns' values (all filterable columns when omitted). */
  filter?: { placeholder?: string; columns?: string[] } | false;
  /** A checkbox column for choosing rows. */
  selectable?: boolean;
  selection?: string[];
  onSelectionChange?: (ids: string[]) => void;
  /** Hide the Columns key. */
  hideViewOptions?: boolean;
  pageSize?: number;
  /** Offer these page sizes under the table. */
  pageSizes?: number[];
  defaultSort?: SortState;
  /** Shown in the body when nothing matches. */
  empty?: React.ReactNode;
  /** Actions at the toolbar's right, before the Columns key. */
  toolbar?: React.ReactNode;
  /** What a row is called in labels ("mailbox"). */
  rowName?: string;
  className?: string;
};

function DataTable<T>({ columns, data, getRowId, filter = {}, selectable = false, selection: selectionProp, onSelectionChange, hideViewOptions = false, pageSize: pageSizeProp = 10, pageSizes, defaultSort = null, empty = "No results.", toolbar, rowName = "row", className }: DataTableProps<T>) {
  const [query, setQuery] = React.useState("");
  const [sort, setSort] = React.useState<SortState>(defaultSort);
  const [page, setPage] = React.useState(0);
  const [pageSize, setPageSize] = React.useState(pageSizeProp);
  const [visible, setVisible] = React.useState<Record<string, boolean>>(() => Object.fromEntries(columns.map((c) => [c.id, !c.hidden])));
  const [selectionInner, setSelectionInner] = React.useState<string[]>([]);
  const selection = selectionProp ?? selectionInner;
  const setSelection = (ids: string[]) => {
    if (!selectionProp) setSelectionInner(ids);
    onSelectionChange?.(ids);
  };

  const shown = columns.filter((c) => visible[c.id] !== false);
  const filterColumns = filter ? columns.filter((c) => c.accessor && (!filter.columns || filter.columns.includes(c.id))) : [];

  const q = query.trim().toLowerCase();
  let rows = q ? data.filter((row) => filterColumns.some((c) => text(c.accessor!(row)).toLowerCase().includes(q))) : data;
  const sortColumn = sort && columns.find((c) => c.id === sort.id);
  if (sort && sortColumn?.accessor) {
    const get = sortColumn.accessor;
    rows = [...rows].sort((a, b) => compare(get(a), get(b)) * (sort.desc ? -1 : 1));
  }

  const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
  const current = Math.min(page, pageCount - 1);
  const pageRows = rows.slice(current * pageSize, current * pageSize + pageSize);
  const pageIds = pageRows.map(getRowId);
  const allOnPage = pageIds.length > 0 && pageIds.every((id) => selection.includes(id));
  const someOnPage = pageIds.some((id) => selection.includes(id));

  const toggleSort = (id: string) => setSort((s) => (s?.id !== id ? { id, desc: false } : s.desc ? null : { id, desc: true }));

  return (
    <div data-slot="data-table" className={cn("grid min-w-0 grid-cols-[minmax(0,1fr)] gap-3", className)}>
      {(filter || !hideViewOptions || toolbar) && (
        <div data-slot="data-table-toolbar" className="flex flex-wrap items-center gap-2">
          {filter && (
            <Input
              variant="search"
              className="max-w-80 min-w-48 flex-1"
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setPage(0);
              }}
              placeholder={filter.placeholder ?? "Filter"}
              aria-label={filter.placeholder ?? "Filter"}
            />
          )}
          <div className="ml-auto flex items-center gap-2">
            {toolbar}
            {!hideViewOptions && (
              <DataTableViewOptions
                columns={columns.filter((c) => c.hideable !== false)}
                visible={visible}
                onVisibleChange={(id, on) => setVisible((v) => ({ ...v, [id]: on }))}
              />
            )}
          </div>
        </div>
      )}
      <div>
        <Table label={`${rowName[0].toUpperCase()}${rowName.slice(1)}s`}>
          <TableHeader>
            <TableRow>
              {selectable && (
                <TableHead className="w-10 align-middle">
                  <Checkbox
                    aria-label={`Select every ${rowName} on this page`}
                    checked={allOnPage ? true : someOnPage ? "indeterminate" : false}
                    onCheckedChange={(on) => setSelection(on === true ? [...new Set([...selection, ...pageIds])] : selection.filter((id) => !pageIds.includes(id)))}
                  />
                </TableHead>
              )}
              {shown.map((c) => (
                <TableHead key={c.id} aria-sort={sort?.id === c.id ? (sort.desc ? "descending" : "ascending") : c.sortable ? "none" : undefined} className={cn(c.align === "end" && "text-right", c.className)}>
                  <DataTableColumnHeader title={c.header} sorted={sort?.id === c.id ? (sort.desc ? "desc" : "asc") : false} onSort={c.sortable && c.accessor ? () => toggleSort(c.id) : undefined} />
                </TableHead>
              ))}
            </TableRow>
          </TableHeader>
          <TableBody>
            {pageRows.length ? (
              pageRows.map((row) => {
                const id = getRowId(row);
                const selected = selection.includes(id);
                return (
                  <TableRow key={id} data-state={selected ? "selected" : undefined}>
                    {selectable && (
                      <TableCell className="w-10 min-w-10">
                        <Checkbox aria-label={`Select ${rowName} ${id}`} checked={selected} onCheckedChange={(on) => setSelection(on === true ? [...selection, id] : selection.filter((s) => s !== id))} />
                      </TableCell>
                    )}
                    {shown.map((c) => (
                      <TableCell key={c.id} label={typeof c.header === "string" ? c.header : undefined} className={cn(c.align === "end" && "text-right", c.className)}>
                        {c.cell ? c.cell(row) : text(c.accessor?.(row))}
                      </TableCell>
                    ))}
                  </TableRow>
                );
              })
            ) : (
              <TableRow className="hover:bg-transparent">
                <TableCell colSpan={shown.length + (selectable ? 1 : 0)} className="h-24 text-center whitespace-normal text-fg-muted">
                  {empty}
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
        <DataTablePagination
          className="mt-3"
          page={current}
          pageCount={pageCount}
          onPageChange={setPage}
          pageSize={pageSize}
          pageSizes={pageSizes}
          onPageSizeChange={(s) => {
            setPageSize(s);
            setPage(0);
          }}
          selected={selectable ? selection.length : undefined}
          total={selectable ? data.length : undefined}
        />
      </div>
    </div>
  );
}

export { DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions };
export type { DataTableColumn, DataTableProps, SortState };

```
