damon-ui
Browse items
uitablecheckboxdropdown-menubuttonskeleton

Data table

A table for admin lists, driven by plain column definitions: sortable columns (client-side or via URL), row selection with a bulk-action bar, a Columns visibility menu, and empty and loading states. No table library. Built on shadcn's Table.

npx shadcn add @damon/data-table

Without the @damon alias: npx shadcn add https://damon-ui.pages.dev/r/data-table.json · raw markdown

Preview
Invoices
INV-0008Southern SignsUnpaid8 Oct 2026$589.33
INV-0006Pounamu PlumbingSent6 Oct 2026$430.95
INV-0004Harbour PhysioPaid4 Oct 2026$272.57
INV-0002Ridgeline BuildersUnpaid2 Oct 2026$114.19
INV-0007Tui Street CafePaid7 Sep 2026$510.14
INV-0005Aroha FloristDraft5 Sep 2026$351.76
INV-0003Matai AccountingOverdue3 Sep 2026$193.38
INV-0001Kauri Joinery LtdPaid1 Sep 2026$35.00

Installs components/ui/data-table.tsx and shadcn's table, checkbox, dropdown-menu, button, skeleton if missing. Pairs with @damon/filter-bar, @damon/pager, @damon/status-badge, @damon/resource-state.

When to use

  • Lists of records in an admin or SaaS app: invoices, orders, customers, jobs.
  • Up to a few hundred rows sorted on the client, or any size sorted and paginated on the server (sortHref + <Pager hrefFor>).
  • When you need bulk actions ("Mark 3 as paid") or let people hide columns.

When NOT to use

  • Spreadsheet-like editing, column resizing, virtualisation of 10k+ rows, grouping or pivoting. Use TanStack Table (shadcn's data-table guide) or AG Grid.
  • Two-column key/value details. Use a <dl>.
  • Simple static tables in content. Use shadcn's Table directly.

Props

DataTableColumn<T>

FieldTypeNotes
idstringStable key for sort, visibility, data-column.
headerReactNode
cell(row: T) => ReactNode
sortValue(row: T) => string | number | bigint | boolean | Date | nullMakes the column sortable on the client. Empty values sort last in both directions; text sorts naturally (INV-2 before INV-10).
sortablebooleanSortable without sortValue (server sorting with sortHref).
numericbooleanRight-aligned, tabular-nums.
hideablebooleanDefault true. Set false for the identifying column.
defaultHiddenbooleanStarts hidden; user can turn it on.
labelstringName in the Columns menu when header isn't a string.
className, headerClassNamestring

DataTable<T>

PropTypeDefaultNotes
columns, rowsrequired
getRowId(row: T) => stringrequired
captionstringrequiredScreen-reader caption.
defaultSort{ id, direction: "asc" | "desc" } | nullnullUncontrolled.
sort, onSortChangeControlled.
sortHref(sort) => stringHeaders become links; rows are rendered in the given order (server sorts).
selectablebooleanfalseRow checkboxes + select-all (indeterminate when partial).
selected, onSelectedChangeSet<string>Controlled selection.
bulkActions(ids, clear) => ReactNodeBar shown while rows are selected.
rowLabel(row) => string"Select row n"Checkbox accessible name.
columnTogglebooleanauto (4+ columns)Columns menu.
toolbarReactNodeLeft of the Columns button.
footerReactNodeUsually a <Pager>.
emptyReactNode"No results."E.g. an <EmptyState>.
loadingbooleanfalseSkeleton rows, aria-busy.
density"compact" | "comfortable""comfortable"
rowClassName(row) => string

Also exported: sortRows(rows, column, direction), compareSortValues(a, b), nextSort(current, id).

Minimal example

"use client"
import { DataTable, type DataTableColumn } from "@/components/ui/data-table"
import { formatMoney } from "@/lib/nz-money"

const columns: DataTableColumn<Invoice>[] = [
  { id: "number", header: "Invoice", cell: (r) => r.number, sortValue: (r) => r.number, hideable: false },
  { id: "customer", header: "Customer", cell: (r) => r.customer, sortValue: (r) => r.customer },
  { id: "total", header: "Total", numeric: true, cell: (r) => formatMoney(r.total), sortValue: (r) => Number(r.total) },
]

<DataTable caption="Invoices" columns={columns} rows={invoices} getRowId={(r) => r.id} />

Real example

Server-sorted, server-paginated list driven by the URL. The page is a server component; the table is a client component given serialisable rows.

// app/invoices/page.tsx (server)
import { buildQuery } from "@/components/ui/pager"
import { InvoiceTable } from "./invoice-table"

export default async function Page({ searchParams }: PageProps<"/invoices">) {
  const sp = await searchParams
  const sort = { id: String(sp.sort ?? "issued"), direction: sp.dir === "asc" ? "asc" : "desc" } as const
  const page = Number(sp.page ?? 1)
  const { rows, total } = await db.invoices.list({ sort, page, pageSize: 25, status: sp.status })
  return <InvoiceTable rows={rows} total={total} page={page} sort={sort} params={{ status: sp.status as string }} />
}

// app/invoices/invoice-table.tsx (client)
"use client"
export function InvoiceTable({ rows, total, page, sort, params }: Props) {
  return (
    <DataTable
      caption="Invoices"
      columns={columns /* sortable: true on server-sortable columns */}
      rows={rows}
      getRowId={(r) => r.id}
      sort={sort}
      sortHref={(s) => `/invoices${buildQuery({ ...params, sort: s.id, dir: s.direction })}`}
      selectable
      rowLabel={(r) => `Select ${r.number}`}
      bulkActions={(ids, clear) => <MarkPaidButton ids={ids} onDone={clear} />}
      empty={<EmptyState title="No invoices yet" action={<Button asChild><Link href="/invoices/new">New invoice</Link></Button>} />}
      footer={
        <Pager
          page={page}
          pageCount={Math.ceil(total / 25)}
          totalItems={total}
          pageSize={25}
          hrefFor={(p) => `/invoices${buildQuery({ ...params, sort: sort.id, dir: sort.direction, page: p })}`}
        />
      }
    />
  )
}

Constraints

  • Client component. Column cell functions can't be passed from a server component; define columns in the client file.
  • Client sorting is for data already on the page. For paginated data, sort on the server, or you'll sort one page only.
  • Selection only counts rows currently displayed, so filtering never leaves hidden rows selected.
  • Keep getRowId stable (database id), not the array index.
  • Narrow screens scroll horizontally inside the table container; keep the identifying column first.

Files

  • ui/data-table.tsx