Data Table
A data grid built on TanStack Table for behavior (sorting, grouping, row selection, column pinning) with Koala styling on top. Drive features from column defs, or compose the styled primitives directly.
Installation#
Usage#
Koala splits the two concerns the way TanStack does. The Table primitives own the look; TanStack owns the behavior. DataTable wires them together: hand it columns and data, flip on the features you want, and ride alignment and pinning along on each column’s meta.
import {
DataTable,
TableCellText,
type ColumnDef,
} from "@/components/ui/data-table"
import { Badge } from "@/components/ui/badge"
type Member = {
id: string
name: string
email: string
status: "Active" | "Invited" | "Suspended"
balance: number
}
const columns: ColumnDef<Member>[] = [
{
accessorKey: "name",
header: "Member",
cell: ({ row }) => (
<TableCellText primary={row.original.name} secondary={row.original.email} />
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ getValue }) => <Badge size="sm">{getValue<string>()}</Badge>,
},
{
accessorKey: "balance",
header: "Balance",
meta: { numeric: true },
cell: ({ getValue }) => `$${getValue<number>()}`,
},
]
export function MembersTable({ data }: { data: Member[] }) {
return (
<DataTable
columns={columns}
data={data}
getRowId={(row) => row.id}
enableSorting
enableRowSelection
/>
)
}Container#
The table is chromeless by default (no border, radius, or fill, with the edge cells pulled flush) so it reads as lined records and drops into whatever already frames it. That is the minimal surface you see throughout this page. When the table needs to stand on its own, set variant="container" to wrap it in the bordered, rounded card. Striping, hover, and pinned columns rebase onto each surface automatically, so every feature keeps working either way.
Left at its default, the table drops straight into the page section it belongs to (a settings panel, a dashboard block) sitting on the page surface with only its own row rules, no box stacked around content that’s already laid out. The minimal surface renders on the page background; for a distinct surface, reach for variant="container".
Flush edges leave no padding to absorb a control’s invisible hit area, so the first and last cells clip sideways: an icon button in the last column grows its 40px target without nudging a table that fits into scrolling by a few pixels. Nothing you could see is cut (the scroll box already ended there), and hit areas still reach up and down between rows.
Toolbar#
The control rail above the table is built in and toggled with simple booleans, no wiring. searchable adds a search box that filters every column (TanStack’s global filter), and toolbarActions is a right-side slot for your own buttons (filter, export, a primary action). The search and any icon buttons are Koala natives (Input and Button), so they share the system’s focus rings, density, and press feedback.
On a phone the rail reflows by itself: the search takes the first row at full width, and the filters and the right-hand actions share the next one, filters from the left and actions pushed to the right edge. A single view-options button never ends up alone on a row of its own, so there is nothing to override and no control to drop for small screens.
Need a layout the toggles don’t cover? The parts: DataTableToolbar, DataTableToolbarSection, and DataTableSearch are exported, so you can compose your own rail and drop it above a plain DataTable.
View options#
viewOptions adds a SlidersHorizontal dropdown to the toolbar for showing and hiding columns. Every column with a label appears as a checkbox, and the menu stays open so you can toggle several at once. Give a column a meta.label for its name there (a string header is used as the fallback); the selection and icon-only action columns have no label, so they’re left out.
enableCardLayout adds a Rows/Cards switch to the same dropdown and renders the rows as a responsive card grid when chosen: the first column becomes the card title, the rest stack as labelled fields, and an icon-only action rides along top-right. Pass renderCard to take over the card entirely. The cards cascade in on load via the shared Stagger primitive.
Filtering#
Beyond the global searchable box, pass filters to add per-column faceted filters. Each field becomes a multi-select dropdown in the toolbar (with the count of matching rows per option) and a removable chip below it; a Reset clears them all. Filtered columns use the built-in filterFn: "arrIncludesSome".
The parts are exported too: DataTableFacetedFilter and DataTableActiveFilters, for hand-composed toolbars over a table instance you own.
Tabs#
Line tabs over a table split one list into views (All, Active, Invited), each with its count. Wrap the table in DataTableTabs, put a DataTableTabsList of DataTableTabs above it, and hand each tab its count. The rows stay yours to filter, from the same place the counts come from; the parts own the rest. The table becomes the panel the tabs control, so a screen reader hears which view it is in, and switching views takes it back to its first page while the search, the sort and the selection that still applies stay put. No remounting the table with a key to reset the page, and nothing lost when you do.
Remembering view state#
Pass a persistKey and the table remembers how the user left it (which columns are visible, their order, the sort, the rows/cards layout, and the page size) by writing them to localStorage under that key. It’s SSR-safe: the server renders the defaults and the saved state is applied after mount, so hydration stays clean. Hide a column or switch to cards below, then reload the page, and it comes back the same.
Use a key that’s unique per table (and per user, if you scope storage that way). Persistence is best-effort: unavailable or full storage fails quietly and the table just opens at its defaults.
Cell types#
A column’s cell renders anything, so a row reads like a record rather than a wall of text. Compose the cells from the same DS parts you use everywhere else: an avatar beside two-line text (TableCellText), a status Badge, a row of tag chips, a directional trend delta tinted success/destructive, an inline sparkline built on Chart in sparkline mode, and an overlapping Avatar stack with a +N overflow. Numbers right-align with meta.numeric (it adds tabular-nums).
Column tooltips#
Two per-column hints ride along on meta, both backed by the shared Tooltip. headerTooltip appends a small info icon after the header label, for explaining what a column measures without crowding the title (hover the Balance header). cellTooltip is a function given the cell context that returns the hint to show for each cell. Return null to skip one. It pairs naturally with a clamped column: truncate the cell and reveal the full value on hover or focus (hover a member name). Both triggers are keyboard-reachable.
The cell hint fills the cell exactly as its content would, so it never changes the column’s layout: a full-width Progress keeps its width (hover an Onboarding bar), text keeps the column’s alignment, and a truncated cell still truncates.
Sorting#
Set enableSorting to make headers click-to-sort. The header shows a muted up/down caret until active, then a solid directional one. Seed an order with initialSorting.
Reordering#
Sorting computes an order; reordering lets the user author one. Pass onRowReorder and every row grows a grip: drag it and the move arrives as the two indices it travelled between, which you apply to your own data (moveItem does the array move). The table never reorders data itself, so the order stays in the source of truth you already own and is yours to save.
The row you grab is lifted, and the rows it passes step aside: the gap that opens is the drop indicator, so there is no line to read and no guessing which side of it you land on. Release and the row eases into the gap before the move is applied, so it settles rather than jumps. The gesture runs on pointer events, not the HTML5 drag-and-drop API, which is what lets it work on touch, keep its own cursor, and pull the view along when you drag past the edge of a scrolling table. Esc cancels mid-drag and leaves the data alone.
The grip is the only drag source, so selecting text in a cell still works, and it has a keyboard twin: focus it and press ↑ or ↓ to walk the row one place at a time, with each move announced to screen readers. A hand-made order only means something while the rows are in data order, so the grips go inert (and say why) under a sort or a grouping.
enableColumnOrdering does the same for columns, and a column travels whole: the header and every cell under it move together, with the columns on either side opening the gap. Grip it, or nudge it with ← / →. That one the table can own outright, so there is nothing to wire; take onColumnOrderChange if you want to know, seed it with initialColumnOrder, or hand the table a persistKey and it remembers the order itself. Columns pinned with meta.sticky stay where they are pinned.
Row hover#
Rows highlight on hover by default: only body rows, never the header. Pass hoverable={false} to opt out (the striped example below does, so the zebra pattern stays crisp).
Striped rows#
striped zebra-stripes even rows for faster scanning. The stripe is scoped to skip hovered and selected rows, so those states always read clearly.
Condensed rows#
Density is Koala’s cross-cutting spacing axis (see Density). For a table it’s the condensed-rows knob: compact is the dense default; comfortable is roomier. Set it per-table or for a whole subtree with DensityProvider.
Grouping#
enableGrouping with an initialGrouping column collapses rows into expandable groups. Give a column an aggregationFn and aggregatedCell to summarize each group; here the balance column sums per team.
Expandable rows#
Pass renderSubRow to reveal a detail panel under a row. It prepends a caret toggle column and renders your node full-width beneath the expanded row. Limit which rows can expand with getRowCanExpand.
With checkboxes#
enableRowSelection prepends a checkbox column with a header “select all” that goes indeterminate on a partial selection. Subscribe with onRowSelectionChange; pass a stable getRowId so selection survives re-sorts.
The selection control is the shared Checkbox component, in checked, indeterminate, and disabled states:
Bulk actions#
With selection on, pass renderSelectionActions and a floating pill rises while rows are selected: the live count, a clear button, and your actions. It’s a dark bar (the Figma/Linear look) that reads as one distinct object over the table, and any Koala Button or menu you drop in re-tints to it automatically. It overlays the bottom of the table out of flow, so it appears and leaves without shifting the toolbar above or anything on the page below. The renderer receives the selected rows and the table instance, so an action can read the selection and reset it. Select a couple of rows below.
Opening a row#
The record-list pattern: click a row and the screen shows that record, in a pane beside the table, a drawer or a page of its own. Pass onRowClick and every row opens on a click anywhere across it, while the controls inside keep their own jobs (the checkbox checks, the row menu opens, a link navigates) and a drag that selects text is left alone. Rows become focusable with a brand ring, and Enter on a focused row opens it, so the keyboard gets the same path as the pointer.
Tell the table which record is open with activeRowId. That row takes a brand tint, deliberately a different color from the checkbox-selected fill, since a row can be picked for a bulk action and open at once, and it carries aria-current. To walk the list from the pane, onDisplayedRowsChange hands you the rows in the order the table shows them, search and sort included, so previous and next follow the screen. Search, re-sort, then step through below.
Sticky header#
stickyHeader pins the header while the body scrolls. Cap the scroll container’s height with containerClassName (e.g. max-h-72) to create the scroll.
Sticky column#
Pin a column to an edge with meta: { sticky: "left" } (or "right"). It stays put, tracking the row’s hover and selected background, while the rest scrolls sideways.
Pagination#
enablePagination pages the rows client-side and drops a Pagination toolbar below the table: prev/next, a “Page X of Y” readout, and a rows-per-page select. Set the initial page size with pageSize and the select’s choices with pageSizeOptions. It shares the table’s density, and pages are disabled while loading.
The page never points past the end. When data shrinks under it (a delete, a filter your screen applies itself) the table steps back to the last page that still exists, and an edit that keeps the row count keeps the page. The rows-per-page select always offers the size in use: left out of pageSizeOptions, it is merged in and sorted, and with no options at all they are built around pageSize (one, two and three pages, then 50 and 100). Go to page 3 below, then remove rows.
Load on scroll#
For server-driven lists, swap paging for infinite scroll: pass onLoadMore and a hasMore flag, and the table fetches the next batch as a sentinel scrolls into view (prefetching ~200px early). Flip loadingMore while the request is in flight; it shows a loading row and blocks duplicate calls. You fetch and append to data; the sentinel watches the nearest scroll container (here a capped max-h wrapper) or the viewport. It replaces the pagination toolbar; the two are mutually exclusive.
Server-side data#
By default the table sorts, filters, and pages the rows it’s given. For large or remote datasets, flip the work to the server: set manualSorting, manualFiltering, and/or manualPagination, then fetch on the onSortingChange / onPaginationChange / onColumnFiltersChange callbacks and feed back the current page as data. Pass pageCount (or rowCount) so the pager knows the total. The demo simulates a 500ms round-trip per sort and page.
Controlled state#
The table keeps its own state until you want it. Pass sorting, globalFilter or columnVisibility with the matching on*Change and that piece is yours: reset it from a button, keep it in the URL, or drive the search from a field elsewhere on the screen (a controlled globalFilter filters with or without the built-in box). Left uncontrolled, every on*Change still reports each change, the built-in search box included, and always after the table has committed it, never from inside a state update, so setting your own state straight from one is safe.
onDisplayedRowsChange reads the other way: it hands you the rows the table is showing, in its order, across every page, after mount and whenever that list changes. Use it for a summary, an export of the current view, or previous and next in a detail pane. Sort or search below and the line under the table follows.
Loading#
Pass loading to swap the rows for skeleton placeholders while data is in flight. They mirror the real column layout (numeric columns get a short right-aligned bar, the select column a checkbox-sized square) so the table doesn’t reflow when data lands. Tune the count with loadingRows; the region is announced with aria-busy.
Empty states#
When there are no rows, the table renders DataTableEmpty, a preset over EmptyState built for the two cases a grid hits. The default is kind="empty" (nothing here yet); override the emptyState prop and pass action buttons as children.
When a search or filter comes up empty, switch to kind="search": the not-found icon and copy, with a “clear filters” escape hatch. With the built-in searchable toolbar this happens automatically: an active search that matches nothing falls back to the search-empty placeholder.
Primitives#
For a static or fully bespoke table, skip TanStack and compose the styled primitives directly: Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell. The same variants (variant, striped, hover, density, sticky) apply.