Vesyl UI

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.

ComponentDescription
DataTableThe table root — takes data and columns
DataTableColumn<T>Column definition shape (type, not a component)
DataTableEmptyStyled empty-state row — optional icon, title, description
TextColumnStyled <span> for cell text — handles truncation and font weight
DateColumnTwo-line cell: relative time by default, absolute timestamp on hover
buildActionsColumnFactory for a trailing ”…” actions column (dropdown menu, pinned)
useRelayConnectionHook 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).

Name
Email
Role
Joined
Ada Lovelace
ada@example.com
Admin
2024-01-12
Alan Turing
alan@example.com
Engineer
2024-02-03
Grace Hopper
grace@example.com
Engineer
2024-02-18
Katherine Johnson
katherine@example.com
Analyst
2024-03-09
Edsger Dijkstra
edsger@example.com
Engineer
2024-03-22
Margaret Hamilton
margaret@example.com
Engineer
2024-04-14
Donald Knuth
donald@example.com
Research
2024-05-05
Barbara Liskov
barbara@example.com
Admin
2024-05-27

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)').

TokenCSS valueTypical use
'2xs'40pxIcons, checkboxes
'xs'60pxShort IDs, action buttons
'sm'100pxStatus, short text
'md'150pxDates, SKU/tracking numbers
'lg'200pxNames, addresses
'xl'300pxLong descriptions
'fill'1frExpands to absorb remaining space

Cell styling

Cell styling is split across two layers:

  • Cell layer (cellProps on the column) — controls the cell container: align, padding, and a className escape 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:

PropValuesUse
font'default' | 'mono'Monospace for IDs, codes, timestamps
tabularbooleanfont-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
truncatebooleanEllipsis 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:

ComponentUse
TextColumnStyled <span> for cell text — font, weight, truncation, tabular numerals (see above)
DateColumnTwo-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.

TypeScript
5.7.2
48,900,000
2024-11-22
React
19.0.0
24,300,000
2024-12-05
Zod
3.24.1
22,500,000
2024-12-11
Tailwind
4.0.0
18,700,000
2025-01-15
Vite
6.0.1
12,100,000
2024-11-26
TanStack Table
8.20.5
3,400,000
2024-09-14
Biome
1.9.4
1,200,000
2024-10-17

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 with renderRow (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. Spread props onto your router’s typed Link (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.

Name
Email
Role

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.

Order
Customer
No orders yet
When your store receives its first order, it will show up here.

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.

Warehouses
Live capacity across regional fulfillment centers.
Code
City
Capacity
SEA-01
Seattle, WA
82%
LAX-02
Los Angeles, CA
64%
DFW-03
Dallas, TX
91%
JFK-04
New York, NY
48%

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.

Tracking
Destination
Carrier
Weight
Status

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} … />.

Timestamp
Actor
Action
Target

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.

Tracking
Origin
Destination
Carrier
Service
Weight
Declared Value
Status
1Z9748948297
Seattle, WA
Los Angeles, CA
UPS
Ground
28.22 lb
$420.00
In transit
1Z0879680255
Portland, OR
Boston, MA
FedEx
2Day
38.19 lb
$1,250.00
Delivered
1Z1109431656
Austin, TX
Seattle, WA
FedEx
Overnight
19.77 lb
$89.00
Pending
1Z3995579732
Denver, CO
Miami, FL
USPS
Priority
37.89 lb
$640.00
Delivered

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.

Reference
Customer
Status
Destination
Carrier
Service
Weight
Items
Declared
Insured
Created
Updated

Footers

DataTable supports two kinds of footer, which compose independently:

  • Column footer (per-column footer field) — 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 (footer prop on DataTable) — a free-form ReactNode rendered 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.

Product
Qty
Total
Widget Pro
12
$480.00
Gadget Plus
5
$249.00
Doohickey
22
$132.00
Sprocket
8
$96.00
Bolt
150
$75.00
Washer
300
$60.00
Nut
240
$48.00
Bracket
18
$162.00
Clamp
14
$84.00
Gasket
40
$120.00
Flange
6
$72.00
Bushing
60
$90.00
Totals
875
$1,668.00

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.

Tracking
Carrier
Destination
Weight
Status
1Z458562093088
USPS
Chicago, IL
1.9 lb
In transit
1Z635568829486
USPS
Nashville, TN
2.3 lb
Pending
1Z156806885730
FedEx
Seattle, WA
1.4 lb
Delivered
1Z311656772159
FedEx
Portland, OR
1.7 lb
Pending
1Z656015966553
UPS
Phoenix, AZ
6.9 lb
Pending
1Z309963763691
FedEx
Nashville, TN
1.8 lb
Pending
1Z718696694821
USPS
Seattle, WA
5.3 lb
Delivered
1Z001900088274
FedEx
Seattle, WA
5.9 lb
Delivered
1Z305319802602
USPS
Portland, OR
5.1 lb
Pending
1Z476733613060
UPS
Chicago, IL
2.8 lb
In transit
1Z282378944102
UPS
Portland, OR
5.8 lb
Delivered
1Z917354942066
UPS
Miami, FL
3.1 lb
In transit
1Z838467190042
UPS
Denver, CO
8.5 lb
Pending
1Z290030918316
FedEx
Atlanta, GA
1.6 lb
In transit
1Z317421243758
USPS
Boston, MA
0.9 lb
In transit
1Z304402759997
USPS
Atlanta, GA
2.3 lb
Delivered
1Z353262732969
DHL
Atlanta, GA
4.0 lb
Out for delivery
1Z398086803965
USPS
Denver, CO
2.4 lb
In transit
1Z132463681511
UPS
Seattle, WA
6.7 lb
Out for delivery
1Z079206458525
USPS
Boston, MA
0.8 lb
Pending
1Z290448847226
USPS
Miami, FL
6.2 lb
Delivered
1Z403231252217
DHL
Phoenix, AZ
8.7 lb
Delivered
1Z375618350692
UPS
Seattle, WA
9.3 lb
Out for delivery
1Z702296702656
USPS
Atlanta, GA
1.1 lb
Delivered
1Z344012383837
UPS
Nashville, TN
7.1 lb
In transit
1Z051580752711
USPS
Miami, FL
8.4 lb
In transit
1Z707288851961
USPS
Boston, MA
3.4 lb
Out for delivery
1Z544053554534
FedEx
Miami, FL
1.0 lb
Pending
1Z942215385148
FedEx
Miami, FL
0.6 lb
In transit
1Z759985293960
USPS
Chicago, IL
4.7 lb
Out for delivery

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.

Order
Customer
Placed
Total
No results.

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.

Label
Value
Row 1
value-1
Row 2
value-2
Row 3
value-3
Row 4
value-4
Row 5
value-5
Row 6
value-6

Reference

DataTable

PropTypeDefaultDescription
dataTData[]Row data (required)
columnsDataTableColumn<TData>[]Column definitions (required)
classNamestringApplied to the root container
getRowId(item: TData) => stringStable row key; required for row selection and recommended otherwise
renderRow(item, props) => ReactNodeRender-prop for the row wrapper; spread props onto your router’s Link
onRowClick(item, index, event) => voidRow click handler; skipped on ctrl/cmd/shift/alt/middle-click
getRowProps(item, index) => React.HTMLAttributes<HTMLElement>Extra attributes merged onto the row element
isLoadingbooleanfalseReplace body with skeletonRows skeletons
skeletonRowsnumber5Skeleton count when isLoading
emptyStateReactNode<DataTableEmpty />Content rendered when data.length === 0
footerReactNodeFooter slot below the body (outside the scroll area)
maxHeightstringCSS max-height on the outer container
minTableWidthnumberMinimum table width in px; triggers horizontal scroll below this
virtualizebooleanfalseEnable row virtualization
rowHeight'sm' | 'md' | 'lg''md'Fixed row height, always applied — required for virtualization accuracy
paginationDataTablePagination{ hasMore, isFetchingNextPage, onLoadMore, totalCount, ensureLoaded } — matches useRelayConnection’s return
loadMoreAheadRowsnumber10Rows before the end that trigger pagination.onLoadMore and skeleton count
isFetchingbooleanfalseExternal fetching signal; shows the header loading bar (use for sort/filter refetches)
showFetchingBarOnLoadMorebooleantrueWhether pagination.isFetchingNextPage also shows the header loading bar
sortingSortingState[]Controlled sort state
onSortingChangeOnChangeFn<SortingState>Sort state change handler; enables manualSorting when provided
enableSelectionbooleanfalseRender the leading checkbox column; click anywhere in the cell to toggle
rowSelectionRowSelectionState{}Controlled selection state keyed by row id
onRowSelectionChangeOnChangeFn<RowSelectionState>Fired when the user toggles a row, range, or the header checkbox
selectionPageSizesnumber[]Turns the header into a dropdown with “Select N” entries (reads pagination.totalCount / ensureLoaded)

DataTableColumn

PropTypeDefaultDescription
idstringUnique column id (required)
headerstringHeader label
widthSemanticWidth | number | string'fill'Width token, pixel number, or any CSS grid track value
accessorKeykeyof TData & stringProperty path on each row; used for sort comparisons and default cell rendering
cell(item: TData, index: number) => ReactNodeCustom cell renderer
enableSortingbooleanfalseOpt a column into sorting
cellProps{ align?, padding?, className? }Per-cell container styling — see Cell styling
stopRowClickbooleanfalseStop click events in this cell from firing onRowClick / renderRow — use for action cells with dropdowns
footerReactNode | ((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

PropTypeDefaultDescription
iconReactNodeIcon rendered above the text
titlestring'No results.'Main heading
descriptionstringMuted helper text
childrenReactNodeExtra content after the text

buildActionsColumn

Factory that returns a DataTableColumn<TData> for a trailing ”…” dropdown-menu column.

OptionTypeDefaultDescription
items(row: TData) => ReactNodeDropdown menu content — return DropdownMenuItem / DropdownMenuSeparator children
ariaLabel(row: TData) => stringAccessible label for the trigger button; per-row context (e.g. 'Actions for {ref}')
idstring'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

OptionTypeDescription
queryTypedDocumentNode<TData, TVariables>GraphQL document; variables must include first: Int and after: String
variablesOmit<TVariables, 'first' | 'after'>Everything except pagination; memoize with useMemo — changes reset accumulation
selectConnection(data: TData) => Connection | null | undefinedPluck { nodes, pageInfo, totalCount } from the query result
pageSizenumberfirst to send per page; defaults to DEFAULT_PAGE_SIZE (50)
enabledbooleanSkip fetching while falsy (e.g. auth still resolving). Default true
clientClientOverride the urql Provider’s client (testing / docs)

useRelayConnection return

FieldTypeDescription
dataTNode[]Accumulated nodes across loaded pages
isLoadingbooleanTrue during the initial page load
isFetchingNextPagebooleanTrue while a subsequent page is fetching
hasMorebooleanpageInfo.hasNextPage of the last loaded page
totalCountnumber | undefinedFrom the last loaded page; undefined before the first page lands
onLoadMore() => voidFetch the next page; no-op if already fetching
ensureLoaded(n: number) => Promise<void>Loop-fetch until data.length ≥ n or hasMore is false
refetch() => voidRe-execute every loaded page with network-only, preserving accumulated pages
errorError | nullLast 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.

Label
Value
Row 1
value-1
Row 2
value-2
Row 3
value-3
Row 4
value-4
Row 5
value-5
Row 6
value-6