Inkbox

Component

Data Table

The console's table with a filter field, sortable heads, row checkboxes, a Columns menu and the lilac pagination bar.

npx -y github:anwarlemu/inkbox-brand-cli#release add data-table
Preview
Actions
acme@inkboxmail.com@acmeNone0%Jul 27, 2026
ops-runner-20@inkboxmail.com@ops-runnerNone16%Jul 27, 2026
support-desk@inkboxmail.com@support-desk313%Jul 28, 2026
acme-support-21@inkboxmail.com@acme-support329%Jul 28, 2026
ops-runner@inkboxmail.com@ops-runner226%Jul 29, 2026
Selection
Actions
acme@inkboxmail.com@acmeNone0%Jul 27, 2026
support-desk@inkboxmail.com@support-desk313%Jul 28, 2026
ops-runner@inkboxmail.com@ops-runner226%Jul 29, 2026
acme-support@inkboxmail.com@acme-support139%Jul 30, 2026
test-agent-233@inkboxmail.com@test-agent-233None52%Jul 31, 2026
Empty
Actions
No mailboxes yet. Mailboxes are created with their agent identity.

Usage

Use for a list of records people manage (mailboxes, agents and lines, webhook subscriptions): pass columns (id, header, an accessor for sorting and filtering, a cell renderer), data and getRowId. Turn on selectable for bulk actions and put those actions in toolbar; set pageSize and pageSizes for long lists. For a short fixed list with no sorting or paging, use Table on its own. Keep row actions as ghost icon buttons in the last column.

Avoid

  • Zebra stripes
  • A border around the whole table instead of the three panels
  • Animating rows when sorting or paging
  • More than one purple pill in the toolbar

Exports: DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions, DataTableColumn, DataTableProps, SortState. This component is a client component.

Dependencies: button, checkbox, dropdown-menu, input-group, pagination, select, table, @phosphor-icons/react.

Example

examples/data-table.tsx
"use client";​import * as React from "react";import { ArrowsClockwiseIcon, PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react/ssr";​import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { DataTable, type DataTableColumn } from "@/components/ui/data-table";​type Mailbox = { address: string; agent: string; webhooks: number; storage: number; created: Date; status: "active" | "paused" };​const AGENTS = ["acme", "support-desk", "ops-runner", "acme-support", "test-agent-233", "billing-bot"];const MAILBOXES: Mailbox[] = Array.from({ length: 23 }, (_, i) => {  const agent = AGENTS[i % AGENTS.length];  return { address: `${agent}${i > 5 ? `-${i}` : ""}@inkboxmail.com`, agent, webhooks: (i * 7) % 4, storage: (i * 13) % 61, created: new Date(2026, 6, 27 + (i % 20)), status: i % 5 === 3 ? "paused" : "active" };});​const date = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" });​const COLUMNS: DataTableColumn<Mailbox>[] = [  { id: "address", header: "Email address", accessor: (m) => m.address, sortable: true, hideable: false },  { id: "agent", header: "Agent", accessor: (m) => m.agent, sortable: true, cell: (m) => <span className="text-rose">@{m.agent}</span> },  { id: "webhooks", header: "Webhooks", accessor: (m) => m.webhooks, sortable: true, cell: (m) => (m.webhooks ? m.webhooks : "None") },  { id: "storage", header: "Storage", accessor: (m) => m.storage, sortable: true, cell: (m) => `${m.storage}%` },  { id: "status", header: "Status", accessor: (m) => m.status, hidden: true, cell: (m) => <Badge variant={m.status === "active" ? "green" : "gray"} size="sm">{m.status === "active" ? "Active" : "Paused"}</Badge> },  { id: "created", header: "Created", accessor: (m) => m.created, sortable: true, cell: (m) => date.format(m.created) },  {    id: "actions",    header: <span className="sr-only">Actions</span>,    hideable: false,    align: "end",    className: "w-14",    cell: (m) => (      <Button variant="ghost" size="icon" aria-label={`Edit ${m.address}`}>        <PencilSimpleIcon weight="fill" />      </Button>    ),  },];​export function Preview() {  return <DataTable columns={COLUMNS} data={MAILBOXES} getRowId={(m) => m.address} rowName="mailbox" pageSize={5} filter={{ placeholder: "Filter mailboxes" }} defaultSort={{ id: "created", desc: false }} empty="No mailbox matches." />;}​export function Selection() {  const [rows, setRows] = React.useState(MAILBOXES.slice(0, 8));  const [selected, setSelected] = React.useState<string[]>([]);  const [refreshing, setRefreshing] = React.useState(false);  return (    <DataTable      columns={COLUMNS}      data={rows}      getRowId={(m) => m.address}      rowName="mailbox"      selectable      selection={selected}      onSelectionChange={setSelected}      pageSize={5}      pageSizes={[5, 10, 20]}      filter={{ placeholder: "Filter mailboxes", columns: ["address", "agent"] }}      toolbar={        <>          <Button            variant="ghost"            size="icon-lg"            aria-label="Refresh"            disabled={refreshing}            onClick={() => {              setRefreshing(true);              setTimeout(() => setRefreshing(false), 800);            }}          >            <ArrowsClockwiseIcon className={refreshing ? "animate-spin-steady" : undefined} />          </Button>          {selected.length > 0 && (            <Button              variant="destructive"              onClick={() => {                setRows((r) => r.filter((m) => !selected.includes(m.address)));                setSelected([]);              }}            >              <TrashIcon weight="fill" />              Delete {selected.length}            </Button>          )}        </>      }    />  );}​export function Empty() {  return <DataTable columns={COLUMNS} data={[]} getRowId={(m) => m.address} filter={false} hideViewOptions empty="No mailboxes yet. Mailboxes are created with their agent identity." />;}

Source

components/ui/data-table.tsx
"use client";​import * as React from "react";import { ArrowDownIcon, ArrowUpIcon, CaretUpDownIcon, ColumnsIcon, MagnifyingGlassIcon } from "@phosphor-icons/react/ssr";​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 { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";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 (the console's Mailboxes table, Figma 427:2715,// Email). It keeps the table's three panels: the 48px lilac head with 14px muted labels (a sortable label is a ghost key// with an arrow for its direction), the white Shadow/S body with 64px rows (a selected row on the lilac) and, 8px below,// the 42px lilac pagination bar with the selection count at its left and the pages at its right. Above it, a toolbar:// a 40px filter field with a magnifying glass and the outline Columns key that opens a menu of checkboxes. Sorting,// filtering and paging are instant (no motion): they are repeated many times in a row.​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={className}>{title}</span>;  const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : CaretUpDownIcon;  return (    <button      type="button"      data-slot="data-table-column-header"      onClick={onSort}      className={cn("-mx-2 inline-flex h-8 items-center gap-1.5 rounded-control px-2 font-sans text-body leading-tight font-normal text-fg-muted outline-none transition-colors duration-150 ease-out hover:bg-subtle-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-focus-ring data-[sorted=true]:text-fg motion-reduce:transition-none", className)}      data-sorted={!!sorted}    >      {title}      <Icon aria-hidden weight={sorted ? "bold" : "regular"} className="size-4" />    </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="outline" className="h-10 shrink-0">          <ColumnsIcon weight="fill" />          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 lilac bar under the table: the selection count, rows per page and the pages. */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 bar className={cn("flex-wrap justify-between gap-x-4 gap-y-1", className)}>      <div className="flex min-h-7.5 flex-wrap items-center gap-x-4 gap-y-1 pl-2 text-body 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 size="sm" aria-labelledby="data-table-page-size" className="h-7.5 w-18 pr-2 pl-2.5">                <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 in the bar. */  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 && (            <InputGroup className="max-w-80 min-w-48 flex-1">              <InputGroupAddon>                <MagnifyingGlassIcon />              </InputGroupAddon>              <InputGroupInput                type="search"                value={query}                onChange={(e) => {                  setQuery(e.target.value);                  setPage(0);                }}                placeholder={filter.placeholder ?? "Filter"}                aria-label={filter.placeholder ?? "Filter"}              />            </InputGroup>          )}          <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>          <TableHeader>            <TableRow className="hover:[&>td]:bg-transparent">              {selectable && (                <TableHead>                  <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>                        <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} className={cn(c.align === "end" && "text-right", c.className)}>                        {c.cell ? c.cell(row) : text(c.accessor?.(row))}                      </TableCell>                    ))}                  </TableRow>                );              })            ) : (              <TableRow className="hover:[&>td]:bg-surface">                <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-1"          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 };

Reference: Figma 427:2715: Email (Mailboxes table and its pagination bar), iMessage (Agents and lines).