Cloud|

@k2b/ui

DataTable

Basic records and professional panels with search, filters, actions, and pagination.

DataTable@k2b/uiPagination@k2b/ui

The basic form renders exact typed records. Sorting, selection, totals, and pagination stay application-owned.

Owner
Requests
Documentation
Design systems
3274
Public API
Platform
18492

TSX

Copy
<DataTable
rows={ordered()}
columns={columns}
getRowId={(row) => row.id}
selectedRowId="api"
sort={sort()}
renderCell={({ col, value, render }) => col.id === "name" ? <strong>{value}</strong> : render(value)}
onSortChange={setSort}
footer={{ values: { name: "Total", requests: 21_766 } }}
/>
<Pagination currentPage={2} totalPages={6} baseUrl="?page=" />
DataTable@k2b/uiFilterChip@k2b/uiTextInput@k2b/ui

The professional composition adds a labelled panel, actions, search, filters, and pagination without moving data ownership into the component.

Orders

3 of 3 rows

Customer
Status
Items
Total
Settings
Alice Becker
Delivered
3
€129.90
Bob Schmidt
Shipped
1
€42.50
Cara Müller
New
5
€219.99

TSX

Copy
const [query, setQuery] = createSignal("");
<DataTable.Panel>
<DataTable.Header title="Orders" subtitle={`${filteredRows().length} of ${total} rows`}>
<Button size="sm" variant="subtle"><i class="ti ti-settings" />Settings</Button>
</DataTable.Header>
<DataTable.Controls>
<TextInput aria-label="Search orders" value={query()} onValueChange={setQuery} icon="ti ti-search" placeholder="Search orders..." clearable />
<FilterChip label="Status" icon="ti ti-filter" value={statuses()} onValueChange={setStatuses} options={statusOptions} />
</DataTable.Controls>
<DataTable
rows={filteredRows()}
columns={columns}
getRowId={(row) => row.id}
footer={{ values: { customer: "Total", items: totals().items, total: totals().total } }}
renderCell={({ row, col, value, render }) => {
if (col.id === "status") {
const status = orderStatus[row.status];
return <StatusBadge label={status.label} tone={status.tone} icon={null} />;
}
if (col.id === "actions") return <Button size="sm" variant="subtle">Open</Button>;
return col.id === "total" ? formatCurrency(value) : render(value);
}}
/>
<DataTable.Footer>
<Pagination currentPage={1} totalPages={6} baseUrl="?page=" />
</DataTable.Footer>
</DataTable.Panel>

DataTable renders typed rows and columns. The server owns filtering, sorting, pagination, aggregation, and permission checks; the component owns table presentation and row interaction.

Use DataTable

Use it for records with consistent fields, comparable values, and column headings.

Use StructuredDataPreview for one small metadata object. Wrap the table in DataTable.Panel when it also needs a title, count, search, filters, actions, or pagination. The standalone DataPanel export remains available for panels whose content is not a table.

When a page shows a single dataset, the page heading already names it. Render the table without a DataTable.Header that repeats the page title, give it an ariaLabel, and put the count in the page subtitle or a statistic. Keep row actions quiet: a ghost button or a row menu, with the danger tone reserved for the confirmation.

Import

tsx
import {
  DataTable,
  type DataTableColumn,
  type DataTableFooter,
  type DataTableProps,
  type DataTableRenderCell,
  type DataTableRenderHeader,
  type DataTableSort,
  Pagination,
  type PaginationProps,
} from "@k2b/ui";

Rows and columns

Each column has a stable id, a header, and usually a value key or function. Numeric values align right by default. renderCell and renderHeader customize presentation without changing the underlying row model.

The default cell wrapper keeps every value on one line and truncates it. With cellContentClass, values can wrap, and a column's nowrap decides: set nowrap: true for dates, times, numbers, amounts, or short codes. Body and footer values then stay on one line and set the column's minimum width; headers may wrap. Without nowrap, the first non-empty value decides: numbers, including bigint, and Date values stay on one line; strings wrap. nowrap: false allows wrapping for numeric or date values too. A white-space set by cellContentClass itself wins over nowrap.

Pass getRowId when selection or stable row identity matters. Use selectedRowId for a selected record, not the row index. Selected rows take the row selection fill without changing text weight or row geometry; see Colors and selection.

The default cell renderer displays missing values as an em dash, dates with the current locale, and booleans as Yes or No.

Assistant view tables may declare a ref column whose cells are Cloud references or null. The cell shows title ?? id; sorting and Copy data use that visible text. References contain only {type, id, title?}, with no URL, and open after a confirmation through the owning app's reader with the viewer's access. Original row-index keys let the host recover the reference after sorting.

Add a header and controls

The basic DataTable stays valid on its own. The compound panel adds only presentation and accessible structure; it does not own query or pagination state.

tsx
<DataTable.Panel>
  <DataTable.Header
    title="Orders"
    subtitle={`${rows.length} of ${total} rows`}
  >
    <Button size="sm" variant="subtle">Settings</Button>
  </DataTable.Header>

  <DataTable.Controls>
    <TextInput
      aria-label="Search orders"
      value={query()}
      onValueChange={setQuery}
    />
    <StatusFilter />
  </DataTable.Controls>

  <DataTable rows={rows} columns={columns} />

  <DataTable.Footer>
    <Pagination currentPage={page} totalPages={pages} baseUrl="?page=" />
  </DataTable.Footer>
</DataTable.Panel>

DataTable.Header accepts primitive title and subtitle props. Its children are actions. They sit beside the title while they fit and wrap onto further rows below it on narrow panels, such as on a phone, so no action is cut off. Controls and Footer accept ordinary child composition, so search, filters, settings, bulk actions, and pagination remain replaceable.

The header automatically labels the nested table region. For a standalone table, use ariaLabel; use ariaLabelledBy when an existing visible heading already owns the label.

DataTableRenderCell receives row, col, the resolved value, and a render callback for the default presentation. DataTableRenderHeader receives the column and its default render callback. DataTableFooter provides per-column values and an optional footer-cell renderer.

Sorting and filtering

Mark a column sortable: true, or provide the server's sort key as a string. Pass the current sort and build a URL in sortHref.

Sorting is link-based so it works on a cold server render and survives reload, sharing, and browser navigation. The same rule applies to filters and pagination: put user intent in the URL, query the server, then pass the returned rows to the table.

For a complete local dataset, pass onSortChange instead of sortHref: sortable headers become buttons. The caller sorts the rows and passes the updated sort. If both are supplied, sortHref takes precedence. A sort changes only the header's aria-sort and arrow, never the button itself, so keyboard focus stays on it and pressing it again reverses the order.

Do not filter or sort a paginated result in the browser. The client does not own the complete dataset.

Presentation

Rows follow the minimal Grids reference: no row lines, grid, or zebra striping; 44px rows (40px compact); a hover fill; a small muted header at weight 500 with no line underneath; numbers aligned right with tabular figures. Tables with more than 8 columns automatically get a hairline between body rows, the approved exception for wide editing tables. There is no prop to change this. data-table-minimal.browser.test.ts pins this threshold and presentation.

Pass loading for the initial read. With no rows, loadingRows renders row-height placeholders (default 5, clamped to an integer from 1 to 50). A refresh keeps existing rows until their replacement arrives. The region is busy while loading and announces “Loading rows” in the inherited locale. Without rows the table cannot infer that a column is numeric, so set align: "right" on numeric columns of a loading table; otherwise their headings move to the right when the first rows arrive.

Use density="compact" for dense operational tables. Headers are sticky unless stickyHeader={false}. footer accepts values and an optional cell renderer for server-computed totals.

DataTable owns its scroll viewport and keeps sticky headers aligned. Avoid wrapping it in a second scrolling container.

Use surface="paper" for a standalone bordered table and surface="plain" when a surrounding section owns the border. Inside AppWorkspace.Main the table sits on Main's surface with surface="plain"; see Paper. Set it explicitly whenever the table also has a custom class. The class sizes the outer table shell; the component keeps scrolling on its inner viewport. Bound a scrolling table with height, max-height, or a correctly sized min-h-0 flex-1 region instead of adding another scroll container.

hasMore, loadingMore, and onLoadMore add an infinite-load sentinel. The owning island still fetches the next server page and appends its rows. The table requests that page when its end comes near the visible area, whether the table scrolls its own viewport or grows with its rows while the page scrolls. While loadingMore is true, the table requests nothing more. Otherwise it requests again after rows or loading state advance, or after the end has left the visible area and returned, so a request the owner ignored is not lost. An owner that omits loadingMore must ignore a repeated call while its page is still loading.

API reference

ts
type DataTableColumn<T> = {
  id: string;
  header: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
  subtitle?: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
  value?: keyof T | ((row: T) => unknown); class?: string; headerClass?: string; cellClass?: string;
  align?: "left" | "center" | "right"; nowrap?: boolean; sortable?: boolean | string;
};

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

type DataTableRenderCell<T> = (ctx: {
  row: T; col: DataTableColumn<T>; value: unknown; render: (value: unknown) => JSX.Element;
}) => JSX.Element;

type DataTableRenderHeader<T> = (ctx: { col: DataTableColumn<T>; render: () => JSX.Element }) => JSX.Element;

type DataTableFooter<T> = {
  values?: Record<string, unknown>;
  renderCell?: (ctx: { col: DataTableColumn<T>; value: unknown; render: (value: unknown) => JSX.Element }) => JSX.Element;
};

type DataTableProps<T> = {
  rows: readonly T[]; columns: readonly DataTableColumn<T>[]; ariaLabel?: string; ariaLabelledBy?: string;
  getRowId?: (row: T) => string; sort?: DataTableSort | null; sortHref?: (next: DataTableSort) => string;
  onSortChange?: (next: DataTableSort) => void;
  selectedRowId?: string | null; rowClass?: string | ((row: T) => string | undefined); hoverRows?: boolean;
  onRowClick?: (row: T) => void; onRowDoubleClick?: (row: T) => void; renderCell?: DataTableRenderCell<T>;
  renderHeader?: DataTableRenderHeader<T>; footer?: DataTableFooter<T>; hasMore?: boolean;
  loading?: boolean; loadingRows?: number;
  loadingMore?: boolean; onLoadMore?: () => void; empty?: JSX.Element; density?: "compact" | "normal";
  stickyHeader?: boolean; highlightColumns?: boolean; verticalAlign?: "top" | "middle" | "bottom";
  cellContentClass?: string; fillHeight?: boolean; surface?: "paper" | "plain"; class?: string;
  tableClass?: string; scrollClass?: string; scrollPreserveKey?: string | false;
};

type DataTablePanelProps = {
  children: JSX.Element; class?: string;
};

type DataTableHeaderProps = {
  title: string; subtitle?: string; children?: JSX.Element; as?: "h1" | "h2" | "h3"; size?: "sm" | "md";
  class?: string;
};

type DataTableControlsProps = {
  children: JSX.Element; class?: string;
};

type DataTablePanelFooterProps = {
  children: JSX.Element; class?: string;
};

The four panel types correspond to DataTable.Panel, .Header, .Controls, and .Footer. All row callbacks receive the original row. Footer values are keyed by column id. getRowId returns a stable string. onLoadMore returns void; expose request state through loadingMore and append rows after success.

Defaults: density="normal", stickyHeader=true, verticalAlign="middle". Use onRowClick/onRowDoubleClick only for real row actions; selection remains controlled through selectedRowId. scrollPreserveKey={false} disables restoration for the table. fillHeight makes the table fill a bounded parent's height. hoverRows defaults to whether a row callback exists; highlightColumns defaults on when row hover is enabled. Omitted surface is paper without a custom class and plain with one, so set it explicitly when styling. rowClass styles rows, tableClass the table, scrollClass the scroll container, and class its outer shell. Pass scrollClass="k2b-detail-trail" to keep column widths beside a workspace detail. The scroller narrows and scrolls the table at its width without the panel; closing returns the full width without extra space. Under an open waiting drawer the scroller gets the drawer's width as space at its end instead.

Accessibility

Sortable headers expose aria-sort. Interactive rows receive keyboard focus and activate with Enter or Space.

Do not make an entire row interactive when it contains unrelated controls. Give action columns an accessible heading, including a visually hidden one when the design does not show text.

Runtime

Rows, headers, sort links, selection, empty state, and footer render on the server. Row callbacks, column hover, and infinite loading require browser code.

Prefer normal links for navigation. Use callbacks only when the interaction cannot be represented by a URL.

Pagination

Pagination is the matching URL-owned page control. PaginationProps contains currentPage, totalPages, baseUrl, and optional onNavigate. The component appends each page number to baseUrl and renders native previous, next, and bounded page links. Invalid page values are clamped to the available range, and the control reacts when the total changes after mount.

tsx
<Pagination
  currentPage={4}
  totalPages={12}
  baseUrl="?page="
/>

Example

tsx
type RouteRow = {
  id: string;
  path: string;
  requests: number;
};

const columns: DataTableColumn<RouteRow>[] = [
  { id: "path", header: "Route", value: "path", sortable: true },
  {
    id: "requests",
    header: "Requests",
    value: "requests",
    sortable: "requestCount",
  },
];

<DataTable
  rows={rows}
  columns={columns}
  getRowId={(row) => row.id}
  sort={{ key: "requestCount", direction: "desc" }}
  sortHref={(next) =>
    `/admin/routes?sort=${next.key}&direction=${next.direction}`
  }
/>

For multi-selection, supply selection={createCollectionSelection({ ids })} and getRowId. The controller may also be shared with FileGrid to retain the same selection between presentations. Rows expose aria-selected and roving keyboard focus. Shift selects a range, Ctrl/Cmd toggles individual rows, Space toggles the focused row, and Ctrl/Cmd-A selects the supplied IDs. Enter invokes onRowDoubleClick in this mode. Existing single-row callbacks remain unchanged when selection is omitted. onRowContextMenu lets the host align selection with its context menu. See File browser for the shared model.

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.