Data Table
The DataTable component provides a data-driven way to display and interact with tabular data, featuring row selection, expansion, sorting, and custom cell rendering. Built on TanStack Table, you describe your columns and hand it your data — it builds the underlying Table for you. Reach for the composable Table instead when you want to lay out rows and cells by hand.
Variations
Column visibility
Pair the useColumnFilter hook with DataTable.ColumnFilter to let people toggle which columns show. Columns must set enableHiding: true and a unique id; the hook persists the choice to localStorage.
Grouped headers
Build multi-row column headers with TanStack Table’s nested columns API — a parent column with a columns array becomes a header group. Give every parent and child an explicit id, set enableSorting: false on parent columns, and keep sorting state keyed by leaf column ids. Ungrouped top-level columns (and injected select/expander columns) span both header rows automatically.
Nested rows (tree mode)
Pass getSubRows to render child rows as indented rows sharing the same columns, instead of a full-width detail panel. Rows with children show an expand/collapse chevron; leaf rows don’t. renderSubComponent is ignored when getSubRows is provided.
Column resizing
Enable live column resizing with enableColumnResizing. Only leaf columns expose a handle on the header boundary. Group parent widths follow the combined width of their visible children. Opt a leaf out with enableResizing: false, and configure sizing with size, minSize, and maxSize.
Resize by dragging the handle with a mouse or touch, or by focusing the handle and using the arrow keys (Shift for larger steps). Sorting stays on the header label — using the handle does not toggle sort.
enableResizing: false prevents direct resizing and leaves that column’s sizing state unchanged when a neighboring column is resized. It does not lock the rendered pixel width in a full-width table: the browser’s automatic table layout may redistribute available space. size is the preferred width, minSize is the lower bound, and maxSize limits direct resizing; set minSize equal to size when the column must not shrink below its configured width.
Guidelines:
- Turn resizing on at the table with
enableColumnResizing. It is off by default. - Use
ColumnDef.enableResizing: falseto remove a leaf column’s resize handle and prevent direct resizing. This does not lock its rendered width in an auto-layout table. - Set
size,minSize, andmaxSizeon leaf columns. UseminSize: sizewhen a non-resizable column must not shrink below its configured width. Group headers do not get handles; their width is derived from child leaves. - Injected
selectandexpandercolumns are not resizable.
Row links
Return a URL from getRowLink to make rows navigable — rows then render as links. Rows disabled via getIsRowDisabled are excluded from link rendering. getRowLink and onRowClick can coexist; the link takes precedence for navigation.
Active row styling
Use getRowClassName to conditionally apply classes to rows based on row data — useful for marking active or highlighted rows. The function receives a TanStack Row; read row.original and return a class string (e.g. cn-table-row-active) or undefined.
Disabled rows
Return true from getIsRowDisabled to disable specific rows. Disabled rows have reduced opacity, show a not-allowed cursor, and cannot be clicked, selected, or expanded. The “Select all” checkbox skips them.
Transparent variant
Set variant="transparent" to remove the background and border styling — ideal for embedding a table in a card or inside an expandable row. All features keep working; pair with size="compact" for a condensed look.
Column pinning
Pass a columnPinning state ({ left, right }) to pin columns to either side so they stay visible during horizontal scrolling. Pinned columns get a subtle shadow. Set explicit column size values for the best control, and avoid pinning so many columns that the scrollable area shrinks too far.
Sticky headers
Set stickyHeader (paired with maxHeight) to render the table in a single scroll viewport so header cells stick to the top while the body scrolls — staying fully opaque, even on sortable-header hover. Sticky offsets support up to two stacked header rows and compose with column pinning.
Row pinning
Opt in to bottom row pinning via TanStack’s rowPinning state — designed for a grand-total row that stays visible at the bottom. Append the total row to data and list its getRowId id in rowPinning.bottom; pair with stickyHeader so the header and total frame the scrolling body. Bottom pinning supports a single row — if multiple ids are listed, only the last one renders.
Installation
import { DataTable } from "@harnessio/ui/components";import { useState } from "react";import type { ColumnDef, SortingState, RowSelectionState, ExpandedState,} from "@tanstack/react-table";
// Define your data typetype User = { name: string; email: string; status: "active" | "inactive";};
// Define your columnsconst columns: ColumnDef<User>[] = [ { accessorKey: "name", header: "Name", enableSorting: true, }, { accessorKey: "email", header: "Email", }, { accessorKey: "status", header: "Status", },];
function MyTable() { // State for sorting, selection, and expansion const [sorting, setSorting] = useState<SortingState>([]); const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [expanded, setExpanded] = useState<ExpandedState>({});
// Your data const data = [ { name: "John Doe", email: "john@example.com", status: "active" }, { name: "Jane Smith", email: "jane@example.com", status: "inactive" }, ];
return ( <DataTable columns={columns} data={data} getRowId={(row) => row.email} currentSorting={sorting} onSortingChange={setSorting} enableRowSelection currentRowSelection={rowSelection} onRowSelectionChange={setRowSelection} enableExpanding currentExpanded={expanded} onExpandedChange={setExpanded} renderSubComponent={({ row }) => ( <div className="p-cn-md"> <p>Additional details for {row.original.name}</p> </div> )} /> );}Anatomy
DataTable is a single, data-driven component — you don’t compose parts. You describe the shape of each column with a TanStack ColumnDef array and hand it your data; internally it renders the composable Table (Table.Root / Header / Body / Row / Head / Cell, plus a Footer for pinned rows).
<DataTable columns={columns} data={data} getRowId={(row) => row.id} enableRowSelection enableExpanding currentSorting={sorting} onSortingChange={setSorting}/>- Columns — each
ColumnDefprovides aheader, anaccessorKey(oraccessorFn), and an optionalcellrenderer for custom content. Nest acolumnsarray on a parent column for grouped headers. - Injected columns — enabling features prepends columns for you:
enableRowSelectionadds a leading checkbox column (with a select-all checkbox in the header), andenableExpandingadds an expander chevron column (detail-panel mode) or an inline chevron on the first column (tree mode viagetSubRows). - Expansion —
renderSubComponentrenders a full-width detail panel beneath an expanded row;getSubRowsinstead renders child rows as indented rows in the same columns. ProvidinggetSubRowsswitches to tree mode and ignoresrenderSubComponent. - Sorting — sorting is manual by default (
manualSortingdefaults totrue): clicking a sortable header callsonSortingChangewith the newSortingState, and you sortdatayourself (e.g. on the server). SetmanualSorting={false}to let the table sort in place. - Pagination — pass
paginationPropsto render a Pagination bar beneath the table. - Sticky / pinned —
stickyHeader+maxHeightrender the table in a scroll viewport with sticky headers;columnPinningpins columns to the sides;rowPinning.bottomrenders a single bottom-pinned row in a<tfoot>.
Best practices
Do
- Provide a stable
getRowIdso selection, expansion, and pinning state track the right rows as data changes. - Give every column a unique
idwhen you usevisibleColumns, column filters, or grouped headers. - Keep custom
cellrenderers lightweight and consistent down a column so rows stay scannable. - Use
getIsRowDisabled(rather than filtering data out) when a row should be visible but not actionable.
Don’t
- Don’t forget to actually sort
datawhen using the default manual sorting — the table won’t reorder rows for you unless you setmanualSorting={false}. - Don’t pin more columns than the layout can spare; leave enough scrollable width for the rest.
- Don’t rely on row color or a status theme alone to convey meaning — keep the value itself explicit in the cell.
Content
- Write column headers as short nouns in sentence case (“Created at”, “Year total”).
- Keep cell content concise; use a custom
cellrenderer for badges or formatting rather than cramming markup into the data. - Use a consistent format for dates and numbers within a column so sorting and scanning behave predictably.
Accessibility
DataTable renders through the semantic Table primitives, so it produces a real HTML <table> with <thead>, <tbody>, and (for pinned rows) <tfoot>.
Keyboard
- The injected controls are keyboard-operable: the select-all and per-row checkboxes are focusable and toggle with Space, and the expander is a real Button that activates with Enter/Space.
- Row selection checkboxes stop click propagation, so toggling a checkbox doesn’t also fire the row’s
onRowClick.
Labeling
- The injected checkboxes carry built-in accessible names — “Select all rows” on the header checkbox and “Select row” on each row — and the expander button is labeled “Toggle Row Expanded”.
- Provide a meaningful
headerfor every column so each column is announced.
Focus & states
- Checkboxes and the expander use the standard focus ring. Disabled rows (
getIsRowDisabled) drop to reduced opacity and a not-allowed cursor, and can’t be clicked, selected, or expanded; the select-all checkbox skips them.
Known limitations
- Sort state isn’t announced. Sortable headers render a sort icon but set no
aria-sort, so screen-reader users aren’t told the current sort direction. - Sorting is mouse-driven. The sortable header is a clickable
<th>with no keyboard affordance of its own, so the sort can’t be toggled from the keyboard — provide an alternative control if keyboard sorting matters. - Selected state is presentational. Row selection styling comes from the Table
selectedstyling; the accessible selected state lives on the row’s checkbox, not on the<tr>(noaria-selected). - A pinned total row appears on every page.
keepPinnedRowsstays at TanStack’s default, so a bottom-pinned row shows on every page — intended for totals, surprising for other rows. If you use row selection, exclude a synthetic totals row viagetRowCanSelect.
Properties
DataTable
Prop | Required | Default | Type |
|---|---|---|---|
| data | true | TData[] | |
| columns | true | ColumnDef<TData, unknown>[] | |
| size | false | 'normal' | 'normal' | 'relaxed' | 'compact' |
| variant | false | 'default' | 'default' | 'transparent' |
| getRowId | false | (row: TData) => string | |
| paginationProps | false | PaginationProps | |
| getRowClassName | false | (row: Row<TData>) => string | undefined | |
| onRowClick | false | (data: TData, index: number) => void | |
| getRowLink | false | (data: TData, index: number) => string | |
| disableHighlightOnHover | false | boolean | |
| className | false | string | |
| currentSorting | false | SortingState | |
| onSortingChange | false | OnChangeFn<SortingState> | |
| manualSorting | false | true | boolean |
| enableRowSelection | false | boolean | |
| currentRowSelection | false | RowSelectionState | |
| onRowSelectionChange | false | OnChangeFn<RowSelectionState> | |
| enableExpanding | false | boolean | |
| getRowCanExpand | false | (row: Row<TData>) => boolean | |
| getRowCanSelect | false | (row: Row<TData>) => boolean | |
| getIsRowDisabled | false | (row: Row<TData>) => boolean | |
| currentExpanded | false | ExpandedState | |
| onExpandedChange | false | OnChangeFn<ExpandedState> | |
| renderSubComponent | false | (props: { row: Row<TData> }) => React.ReactNode | |
| getSubRows | false | (originalRow: TData, index: number) => TData[] | undefined | |
| initiallyExpandAllRows | false | false | boolean |
| enableColumnResizing | false | false | boolean |
| visibleColumns | false | string[] | |
| columnPinning | false | ColumnPinningState ({ left?: string[], right?: string[] }) | |
| stickyHeader | false | boolean | |
| maxHeight | false | CSSProperties['maxHeight'] | |
| rowPinning | false | RowPinningState ({ top?: string[], bottom?: string[] }) | |
| onRowPinningChange | false | OnChangeFn<RowPinningState> | |
| enableRowPinning | false | boolean | ((row: Row<TData>) => boolean) |
DataTable.ColumnFilter
A sub-component that provides a dropdown menu for toggling column visibility.
Prop | Required | Default | Type |
|---|---|---|---|
| columns | true | CheckboxOptions[] | |
| visibleColumns | true | string[] | |
| onCheckedChange | true | (columnName: string, checked: boolean) => void | |
| onReset | false | () => void |
useColumnFilter parameters
The useColumnFilter hook manages column visibility state with automatic localStorage persistence.
Prop | Required | Default | Type |
|---|---|---|---|
| storageKey | true | string | |
| columns | true | CheckboxOptions[] | |
| defaultVisibleColumns | false | string[] |
useColumnFilter return value
Prop | Required | Default | Type |
|---|---|---|---|
| visibleColumns | false | string[] | |
| toggleColumn | false | (columnName: string, checked: boolean) => void | |
| resetColumns | false | () => void |