DataTable
A typed columnar data table built on TanStack Table and TanStack Virtual. Declarative column definitions, controlled or uncontrolled sort state, optional row virtualization, an infinite-loading hook for paginated APIs, and a row-selection model that plays nicely with bulk actions.
| Component | Description |
|---|---|
DataTable | The table root — takes data and columns |
DataTableColumn<T> | Column definition shape (type, not a component) |
DataTableEmpty | Styled empty-state row — optional icon, title, description |
TextColumn | Styled <span> for cell text — handles truncation and font weight |
DateColumn | Two-line cell: relative time by default, absolute timestamp on hover |
buildActionsColumn | Factory for a trailing ”…” actions column (dropdown menu, pinned) |
useRelayConnection | Hook that adapts a Relay-paginated source to DataTable props |
Basic usage
Declare columns once (typed against your row shape) and pass them with data. The table renders borderless by default — wrap it in <Card> when you want chrome (see Inside a Card).
Columns
Each column is a typed object. accessorKey reads the property for sorting and default rendering; cell lets you render arbitrary content. If neither is set, the column renders nothing (useful for action/icon columns with only a cell renderer). Sorting is opt-in — set enableSorting: true per column.
Widths
Pass a SemanticWidth token for common sizes, a pixel number, or any CSS grid track value (e.g. 'minmax(100px, 1fr)').
| Token | CSS value | Typical use |
|---|---|---|
'2xs' | 40px | Icons, checkboxes |
'xs' | 60px | Short IDs, action buttons |
'sm' | 100px | Status, short text |
'md' | 150px | Dates, SKU/tracking numbers |
'lg' | 200px | Names, addresses |
'xl' | 300px | Long descriptions |
'fill' | 1fr | Expands to absorb remaining space |
Cell styling
Cell styling is split across two layers:
- Cell layer (
cellPropson the column) — controls the cell container:align,padding, and aclassNameescape hatch. - Content layer (
<TextColumn>) — controls the text element itself:font,tabular,fontWeight,truncate.
cellProps.align — 'left' (default) / 'center' / 'right'. Applies to header and body cells in the column.
cellProps.padding — 'default' (the usual px-3 py-2) or 'none' (removes vertical padding). Use 'none' when the cell’s content manages its own vertical space, e.g. <DateColumn>.
cellProps.className — raw escape hatch for anything not covered by the named props.
<TextColumn> props:
| Prop | Values | Use |
|---|---|---|
font | 'default' | 'mono' | Monospace for IDs, codes, timestamps |
tabular | boolean | font-variant-numeric: tabular-nums — equal-width digits in proportional fonts. Use for numeric columns (money, counts) and alphanumeric IDs where digit runs should line up |
fontWeight | 'normal' | 'medium' | 'semibold' | 'bold' | Text weight |
truncate | boolean | Ellipsis overflow (default true) |
For action cells (dropdowns, inline controls), set stopRowClick: true on the column so clicks don’t also fire onRowClick.
// Numeric column: right-aligned, tabular digits
{
id: 'weight',
header: 'Weight',
width: 'sm',
cellProps: { align: 'right' },
cell: (s) => <TextColumn tabular>{s.weight} lb</TextColumn>,
}
// Monospace ID column
{
id: 'tracking',
header: 'Tracking',
width: 'md',
cell: (s) => <TextColumn font="mono">{s.tracking}</TextColumn>,
}
// Date cell — component owns its height, so remove cell padding
{
id: 'createdAt',
header: 'Created',
width: 'md',
cellProps: { padding: 'none' },
cell: (o) => (
<DateColumn relative={...} absolute={...} />
),
}Content components
Reusable cell-content wrappers, exported from @vesyl/ui-next:
| Component | Use |
|---|---|
TextColumn | Styled <span> for cell text — font, weight, truncation, tabular numerals (see above) |
DateColumn | Two-line date cell: relative time by default, absolute timestamp on hover. Date-library-agnostic — caller formats both strings |
DateColumn takes relative and absolute as ReactNode so any date library works:
<DateColumn
relative={dayjs(order.createdAt).fromNow()}
absolute={dayjs(order.createdAt).format('MM/DD/YYYY h:mm A')}
/>Pair it with cellProps: { padding: 'none' } on the column — DateColumn manages its own vertical layout for the hover transition.
Column builders
Helpers that return a full DataTableColumn<TData> with sensible defaults baked in. Reach for these when a column has a consistent config-level shape (pin, width, stopRowClick) alongside its rendering.
buildActionsColumn
Trailing ”…” dropdown-menu column. Supplies the dropdown wrapper, trigger button, and MoreHorizontal icon; you pass the menu items. Defaults to right-pinned, xs width, sorting disabled, and stopRowClick: true so clicking the menu never also fires onRowClick or navigates the row’s renderRow anchor.
import { buildActionsColumn, DropdownMenuItem } from '@vesyl/ui-next'
const columns = [
// ...other columns,
buildActionsColumn<Order>({
ariaLabel: (o) => `Actions for ${o.reference}`,
items: (o) => (
<>
<DropdownMenuItem onClick={() => view(o)}>View</DropdownMenuItem>
<DropdownMenuItem onClick={() => edit(o)}>Edit</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => cancel(o)}>
Cancel
</DropdownMenuItem>
</>
),
}),
]Pass ariaLabel: (row) => string so the trigger button gets a row-specific accessible name (e.g. 'Actions for VS-100042'); without it screen readers just announce “button”. Override the pin by passing pin: 'left' or pin: false — the latter leaves the column unpinned, useful for tables without horizontal scroll.
Sorting
Sorting is opt-in per column — set enableSorting: true on the columns you want sortable. Drive sort state externally by passing sorting and onSortingChange. When onSortingChange is provided, DataTable flips to manualSorting mode — you’re responsible for ordering the data (typically server-side or in a memo).
Headers show a low-opacity double-chevron when sortable but inactive, and a solid up/down arrow when sorted — layout doesn’t shift when you toggle between states.
Row interactions
DataTable renders each row as a <div> by default; renderRow lets you swap in any element for the row wrapper.
onRowClick(item, index, event)— click handler. When combined withrenderRow(anchor-style rows), it’s skipped on ctrl/cmd/shift/alt/middle-click so the browser’s new-tab behavior isn’t double-fired by in-app navigation.renderRow(item, props)— render-prop for the row element. Spreadpropsonto your router’s typedLink(TanStack Router, React Router, Next.js, etc.) for client-side navigation.getRowProps(item, index)— returns extra attributes applied to the row element (e.g.data-*attributes,aria-*,onMouseEnter).
With TanStack Router:
import { Link } from "@tanstack/react-router";
<DataTable
data={rows}
columns={columns}
renderRow={(row, props) => (
<Link to="/orgs/$id" params={{ id: row.id }} {...props} />
)}
/>;The consumer’s Link handles routing, focus, and modifier-click natively; DataTable supplies className, style, role="row", data-slot, and the row’s cell children via props. Type-safe route paths and params are preserved because the consumer’s typed Link is never erased.
Click a row to "open" the order. Ctrl/Cmd-click or middle-click opens the anchor target in a new tab without firing onRowClick.
Loading and empty states
Pass isLoading to render skeleton rows instead of the body. skeletonRows controls how many are drawn (default 5). Use this for the initial fetch — for paginated loading see Infinite loading.
When data.length === 0 and isLoading is false, DataTable renders an empty-state row. Pass emptyState for a custom node, or let the default DataTableEmpty render with icon, title, and description.
Inside a Card
DataTable renders borderless. Wrap it in <Card className="overflow-hidden p-0"> when you want a bordered, rounded surface — the Card’s overflow-hidden clips the table’s corners cleanly. If the Card already has a CardHeader, pass className="border-t" on DataTable to separate the header from the table.
Virtualization
For long lists, enable virtualize and give the table a constrained height. The table uses TanStack Virtual under the hood; rows are rendered only when scrolled into view. rowHeight pins every row to a fixed height — required for accurate virtual offsets (and applied in non-virtual mode too so regions align).
Row heights: 'sm' = 40px, 'md' = 48px, 'lg' = 56px.
Infinite loading
Pair virtualization with a pagination object — { hasMore, isFetchingNextPage, onLoadMore } — to page through a server-backed list. loadMoreAheadRows controls how many rows before the end trigger the next fetch and how many skeleton rows appear below the loaded data.
Set loadMoreAheadRows to your page size — e.g. if the server returns 50 at a time, use 50. onLoadMore is guarded against double-firing — it won’t call a second time until data.length actually grows.
DataTable doesn’t care where rows come from — it just wants a flat TData[] plus the pagination object. For Relay-shaped GraphQL sources (which is what you’ll almost always be wiring up in a VESYL app), use useRelayConnection; its return matches the pagination shape so you can pass it directly: <DataTable pagination={connection} … />.
Horizontal scroll
By default the table fills its container width and compresses 'fill' columns to fit. To require a minimum content width (and let the container scroll horizontally below it), pass minTableWidth in pixels. The sticky header scrolls horizontally along with the body.
Pinned columns
Set pin: 'left' or pin: 'right' on a column to keep it in place while the rest scrolls horizontally. DataTable renders three independent grid regions — left, center, right — each with its own sticky header and footer; only the center region owns the horizontal scrollbar. A subtle shadow appears on the inner edge of each pinned region when content is hidden past it.
Pinned columns need a pixel-resolvable width — a number (e.g. 120) or a semantic token ('2xs' through 'xl'). 'fill'/'1fr' and arbitrary CSS widths aren’t supported on pinned columns; in dev, the column falls back to 'md' with a console warning. Unpinned columns can still use 'fill' freely — each region builds its own grid template from its subset of columns.
When enableSelection is on, the injected checkbox column is always left-pinned, so it stays visible as the user scrolls horizontally.
Footers
DataTable supports two kinds of footer, which compose independently:
- Column footer (per-column
footerfield) — a summary row aligned to the same grid template as the body, rendered inside the scroll container and pinned to the bottom edge. Widths track column widths automatically, and the row scrolls horizontally in lock-step with the body. - Footer slot (
footerprop on DataTable) — a free-formReactNoderendered below the scroll area for buttons, status lines, or anything that shouldn’t be column-aligned.
Both require a bounded table height (maxHeight, virtualize, or a parent height). Without it the table sizes to content and there’s nothing for the scroll area to shrink against.
Set footer on each column that should contribute a summary cell — a ReactNode for static content, or a function receiving the loaded rows for aggregates. Only columns that define footer get non-empty cells; the rest render empty, preserving alignment. The row mirrors the header’s look (bg-muted, cell-matching padding) and respects each column’s cellProps.align. The column footer and the free-form footer prop stack when both are set.
Row selection
Pass enableSelection to render a leading checkbox column. DataTable injects the column automatically — don’t add it to your columns array. Drive the state with rowSelection and onRowSelectionChange (both typed as TanStack Table’s RowSelectionState). getRowId is required so selections survive sort changes and pagination.
Clicking anywhere inside a body selection cell toggles the row — the entire cell is the hit target, not just the checkbox itself. Holding Shift while clicking selects every row between the click and the last-selected row, setting all of them to the newly clicked row’s target state.
When selectionPageSizes is provided, the header checkbox becomes a dropdown trigger that opens a “Select N” menu with a “Clear selection” item. Each entry selects the first N rows in current sort order. When totalCount is provided and is less than or equal to the largest page size, entries >= totalCount are dropped and a Select all {totalCount} entry is appended — so the user can bulk-select up to the known total without overshooting.
For paginated tables, pass ensureLoaded: (n) => Promise<void>. The menu calls it before applying selection whenever N exceeds the loaded row count; the selected menu item shows a spinner until the promise resolves and enough rows have landed. If omitted, the menu selects only from the currently loaded rows.
Click a cell to toggle. Shift-click to select a range. Use the caret next to the header checkbox to select in bulk — because totalCount is 30 and below the largest page size (50), the menu caps the last entry at "Select all 30". No rows selected.
Relay connection adapter
useRelayConnection wires a Relay-shaped paginated source ({ nodes, pageInfo, totalCount }) into DataTable. It handles cross-page accumulation, resets when variables change, and exposes ensureLoaded(n) so the selection menu’s “Select N” can fetch pages until the target is reached.
Pass an urql TypedDocumentNode, the variables other than first/after, and a selectConnection function that plucks the connection out of the query result. The hook opens one reactive urql subscription per loaded page, so cache writes from mutation results, graphcache updates resolvers, and explicit refetch() calls can update loaded pages in place.
import { useMemo, useState } from "react";
import { gql, type TypedDocumentNode } from "@urql/core";
import { DataTable, useRelayConnection } from "@vesyl/ui-next";
const OrdersQuery: TypedDocumentNode<OrdersData, OrdersVars> = gql`
query Orders($first: Int!, $after: String, $search: String) {
orders(first: $first, after: $after, search: $search) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { id number customer total }
}
}
`;
function OrdersTable() {
const [search, setSearch] = useState("");
const variables = useMemo(() => ({ search }), [search]);
const connection = useRelayConnection({
query: OrdersQuery,
variables,
pageSize: 50,
selectConnection: (data) => data.orders,
});
return (
<DataTable
data={connection.data}
columns={columns}
getRowId={(o) => o.id}
isLoading={connection.isLoading}
virtualize
pagination={connection}
loadMoreAheadRows={50}
enableSelection
selectionPageSizes={[25, 50, 100, 200]}
/>
);
}The demo below uses a mocked urql client with a canned exchange (400ms latency, 437 records, customer-name filter) — no network required.
Mock connection (400ms latency, 50/page, 437 total). Scroll to load more; use the selection menu to "Select 100" — the hook's ensureLoaded fetches pages until 100 rows are loaded, then selection applies. 0 loaded.
Fetching indicator
A 2px animated bar shows beneath the header while the table is fetching. It triggers on pendingSize !== null (selection ensureLoaded), isFetchingNextPage (infinite scroll, opt out with showFetchingBarOnLoadMore={false}), or a consumer-driven isFetching prop for server-side sort/filter refetches.
Reference
DataTable
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | — | Row data (required) |
columns | DataTableColumn<TData>[] | — | Column definitions (required) |
className | string | — | Applied to the root container |
getRowId | (item: TData) => string | — | Stable row key; required for row selection and recommended otherwise |
renderRow | (item, props) => ReactNode | — | Render-prop for the row wrapper; spread props onto your router’s Link |
onRowClick | (item, index, event) => void | — | Row click handler; skipped on ctrl/cmd/shift/alt/middle-click |
getRowProps | (item, index) => React.HTMLAttributes<HTMLElement> | — | Extra attributes merged onto the row element |
isLoading | boolean | false | Replace body with skeletonRows skeletons |
skeletonRows | number | 5 | Skeleton count when isLoading |
emptyState | ReactNode | <DataTableEmpty /> | Content rendered when data.length === 0 |
footer | ReactNode | — | Footer slot below the body (outside the scroll area) |
maxHeight | string | — | CSS max-height on the outer container |
minTableWidth | number | — | Minimum table width in px; triggers horizontal scroll below this |
virtualize | boolean | false | Enable row virtualization |
rowHeight | 'sm' | 'md' | 'lg' | 'md' | Fixed row height, always applied — required for virtualization accuracy |
pagination | DataTablePagination | — | { hasMore, isFetchingNextPage, onLoadMore, totalCount, ensureLoaded } — matches useRelayConnection’s return |
loadMoreAheadRows | number | 10 | Rows before the end that trigger pagination.onLoadMore and skeleton count |
isFetching | boolean | false | External fetching signal; shows the header loading bar (use for sort/filter refetches) |
showFetchingBarOnLoadMore | boolean | true | Whether pagination.isFetchingNextPage also shows the header loading bar |
sorting | SortingState | [] | Controlled sort state |
onSortingChange | OnChangeFn<SortingState> | — | Sort state change handler; enables manualSorting when provided |
enableSelection | boolean | false | Render the leading checkbox column; click anywhere in the cell to toggle |
rowSelection | RowSelectionState | {} | Controlled selection state keyed by row id |
onRowSelectionChange | OnChangeFn<RowSelectionState> | — | Fired when the user toggles a row, range, or the header checkbox |
selectionPageSizes | number[] | — | Turns the header into a dropdown with “Select N” entries (reads pagination.totalCount / ensureLoaded) |
DataTableColumn
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique column id (required) |
header | string | — | Header label |
width | SemanticWidth | number | string | 'fill' | Width token, pixel number, or any CSS grid track value |
accessorKey | keyof TData & string | — | Property path on each row; used for sort comparisons and default cell rendering |
cell | (item: TData, index: number) => ReactNode | — | Custom cell renderer |
enableSorting | boolean | false | Opt a column into sorting |
cellProps | { align?, padding?, className? } | — | Per-cell container styling — see Cell styling |
stopRowClick | boolean | false | Stop click events in this cell from firing onRowClick / renderRow — use for action cells with dropdowns |
footer | ReactNode | ((rows: TData[]) => ReactNode) | — | Summary cell for the pinned footer row — see Footers |
pin | 'left' | 'right' | — | Pin to the left or right region — see Pinned columns. Pinned columns require a pixel-resolvable width |
DataTableEmpty
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Icon rendered above the text |
title | string | 'No results.' | Main heading |
description | string | — | Muted helper text |
children | ReactNode | — | Extra content after the text |
buildActionsColumn
Factory that returns a DataTableColumn<TData> for a trailing ”…” dropdown-menu column.
| Option | Type | Default | Description |
|---|---|---|---|
items | (row: TData) => ReactNode | — | Dropdown menu content — return DropdownMenuItem / DropdownMenuSeparator children |
ariaLabel | (row: TData) => string | — | Accessible label for the trigger button; per-row context (e.g. 'Actions for {ref}') |
id | string | 'actions' | Column id |
pin | 'left' | 'right' | false | 'right' | Pin position; false leaves the column unpinned |
The returned column also sets width: 'xs', enableSorting: false, and stopRowClick: true — override by spreading the result and replacing fields.
useRelayConnection options
| Option | Type | Description |
|---|---|---|
query | TypedDocumentNode<TData, TVariables> | GraphQL document; variables must include first: Int and after: String |
variables | Omit<TVariables, 'first' | 'after'> | Everything except pagination; memoize with useMemo — changes reset accumulation |
selectConnection | (data: TData) => Connection | null | undefined | Pluck { nodes, pageInfo, totalCount } from the query result |
pageSize | number | first to send per page; defaults to DEFAULT_PAGE_SIZE (50) |
enabled | boolean | Skip fetching while falsy (e.g. auth still resolving). Default true |
client | Client | Override the urql Provider’s client (testing / docs) |
useRelayConnection return
| Field | Type | Description |
|---|---|---|
data | TNode[] | Accumulated nodes across loaded pages |
isLoading | boolean | True during the initial page load |
isFetchingNextPage | boolean | True while a subsequent page is fetching |
hasMore | boolean | pageInfo.hasNextPage of the last loaded page |
totalCount | number | undefined | From the last loaded page; undefined before the first page lands |
onLoadMore | () => void | Fetch the next page; no-op if already fetching |
ensureLoaded | (n: number) => Promise<void> | Loop-fetch until data.length ≥ n or hasMore is false |
refetch | () => void | Re-execute every loaded page with network-only, preserving accumulated pages |
error | Error | null | Last fetch error, cleared on next fetch |
Layout contract
Regression canaries for the scroll-wrapper sizing chain. The table must size to its intrinsic content height with no outer height constraint, and no internal scrollbar should engage. If this demo grows a scrollbar or collapses to 0px, the SCROLL_AREA contract in data-table.tsx has regressed.