Data tables
Every list surface in Checkstack renders through one component: DataTable from @checkstack/ui. It gives each table click-to-sort headers, a global search box, and a readable opaque surface, while leaving cell rendering, per-row access gating, selection, and actions fully in your hands. Sort and filter state are powered by @tanstack/react-table; you never touch that API directly.
When to use it
Section titled “When to use it”Use DataTable for any homogeneous list of records shown as aligned columns - systems, health checks, users, runs, providers, and so on. It replaces the older pattern of hand-composing the Table primitives with a separate mobile card list. For card galleries, pickers, editors, and stat strips (heterogeneous or form-like surfaces), keep the purpose-built layout - a grid adds no value there.
Column contract
Section titled “Column contract”A column owns its own rendering and, optionally, how it sorts and how it is searched. Sorting is enabled by providing sortValue; searching by providing searchValue - there are no separate boolean flags.
export interface DataTableColumn<TData> { id: string; header: React.ReactNode; cell: (row: TData) => React.ReactNode; /** Provide to make the header click-to-sort (asc -> desc -> unsorted). */ sortValue?: (row: TData) => string | number | null | undefined; /** Provide to include this column's text in the global search box. */ searchValue?: (row: TData) => string; headClassName?: string; cellClassName?: string; /** Hide this lower-priority column below the `md` breakpoint. */ desktopOnly?: boolean;}Strings sort locale-aware and case-insensitively (so item 2 precedes item 10); numbers sort numerically; null/undefined always sort last. Leave sortValue/searchValue off purely-visual columns such as chip clusters or action buttons.
Basic usage
Section titled “Basic usage”import { DataTable, type DataTableColumn } from "@checkstack/ui";
const columns: DataTableColumn<System>[] = [ { id: "name", header: "Name", cell: (s) => <span className="font-medium">{s.name}</span>, sortValue: (s) => s.name, searchValue: (s) => s.name, }, { id: "status", header: "Status", cell: (s) => <HealthBadge status={s.status} />, sortValue: (s) => s.status, },];
<DataTable data={systems} columns={columns} getRowId={(s) => s.id} searchPlaceholder="Search systems..." defaultSort={{ columnId: "name", direction: "asc" }} emptyState={<ListEmptyState resource="systems" />} noResultsState={<ListEmptyState resource="systems" description="No matches." />}/>;Selection
Section titled “Selection”Selection is not a special prop - model it as an ordinary leading column. Put your “select all” checkbox in the column header and the per-row checkbox in cell, wired to your own state. Because sort and search are keyed by getRowId, “select all visible” stays correct after sorting or filtering. Use getRowProps to reflect the selected highlight:
{ id: "select", headClassName: "w-10", header: <Checkbox checked={allSelected} onCheckedChange={toggleAll} aria-label="Select all" />, cell: (s) => ( <Checkbox checked={selected.has(s.id)} disabled={!canManage(s.id)} onCheckedChange={() => toggle(s.id)} aria-label={`Select ${s.name}`} /> ),}// ...<DataTable /* ... */ getRowProps={(s) => ({ selected: selected.has(s.id) })} />Mobile
Section titled “Mobile”Pass renderMobileCard to swap to a stacked card layout below the sm breakpoint. The cards render from the same filtered + sorted rows, so search and sort apply on mobile too. Omit it to keep the table (with horizontal scroll) at every width.
<DataTable data={systems} columns={columns} getRowId={(s) => s.id} renderMobileCard={(s) => ( <Card className="p-3"> <p className="font-medium">{s.name}</p> <HealthBadge status={s.status} /> </Card> )}/>Row actions
Section titled “Row actions”Row action buttons (edit, delete, and friends) must look identical in every
table. Use the shared RowActions container with RowAction items rather than
hand-rolling buttons - RowAction is the one canonical style: a subtle,
compact ghost icon button. tone="destructive" only tints it red; it is never
a loud filled button, so a delete carries the same visual weight as an edit
everywhere.
import { RowActions, RowAction } from "@checkstack/ui";import { Pencil, Trash2 } from "lucide-react";
{ id: "actions", header: "Actions", headClassName: "text-right", cellClassName: "text-right", cell: (row) => ( <RowActions> <RowAction icon={Pencil} label={`Edit ${row.name}`} onClick={() => onEdit(row)} /> <RowAction icon={Trash2} tone="destructive" label={`Delete ${row.name}`} disabled={row.locked} title={row.locked ? "Managed by GitOps" : undefined} onClick={() => onDelete(row.id)} /> </RowActions> ),}Pass the lucide icon component (icon={Trash2}), not an element. label is the
accessible name and default tooltip; title overrides the tooltip (e.g. a lock
reason). Never drop a variant="destructive" filled button into an actions
column - that is exactly the inconsistency RowAction exists to prevent.
Filtering
Section titled “Filtering”A facet is a “narrow by one dimension” control - status, severity, type, team. The table renders them beside the search box, applies them, and offers a Clear affordance once anything is constrained.
Filterable columns (the default)
Section titled “Filterable columns (the default)”Filtering joins sorting and searching on the column contract: providing filterValue is what makes a column filterable, with no separate boolean flag.
{ id: "severity", header: "Severity", cell: (a) => <SeverityBadge severity={a.severity} />, sortValue: (a) => severityRank[a.severity], filterValue: (a) => a.severity, // <- the column is now filterable filterOptions: SEVERITY_OPTIONS, // omit to derive from the data filterKind: "pills", // optional; defaults to a select}Declare it here rather than in a standalone facet whenever a single column owns the dimension. The column already reads the row for sortValue and renders it in cell, so the value is stated ONCE and the badge, the sort and the filter cannot drift apart.
This colocates the DECLARATION, not the rendering. Every filter - column-derived or standalone - renders in one shared bar above the table, in column order; a column’s control does not appear at its header. That is deliberate: a single row is one place to scan, it is the only thing that works for filters no column owns, and it collapses onto a narrow viewport without crowding the sort affordances.
filterOptions is optional. Omit it and the options are derived from the distinct values present in data, sorted and labelled by the raw value. Declare it when:
- the raw values are not what a person should read (
authenticated-> “Authenticated only”), - the order carries meaning (severity by impact - deriving sorts alphabetically into critical / info / warning), or
- an option must stay on offer even when no row currently has it.
Options are always derived from the full data, never from what is currently visible. Reading them off the filtered rows would let selecting one option delete every other option, stranding you with no way back.
Use filterLabel when the column’s header is an icon or an element rather than a string, since the header is otherwise the control’s label.
Facets no column owns
Section titled “Facets no column owns”The standalone facets prop stays for a dimension no single column can express - one matching several values per row, or shared across two row types:
<DataTable data={systems} columns={columns} getRowId={(s) => s.id} facets={[groupFacet]} />Column-derived facets render first, in column order, followed by these. Everything is ANDed with the free-text search.
An id doubles as the URL parameter name, so keep it short and URL-safe. A row whose value matches no offered option is simply never shown while that facet is constrained, and a facet the table does not declare is ignored - so a stale link degrades to “less filtered” rather than to an empty table nobody can explain.
Do NOT pre-filter data yourself and hand the table the survivors. emptyState fires when data is empty and noResultsState when the filters empty it, and upstream filtering collapses that distinction: “nothing here yet” starts rendering as “nothing matches”.
Pills, and when to tone them
Section titled “Pills, and when to tone them”kind: "pills" renders a segmented row instead of a dropdown - right for two or three short options worth seeing at a glance, wrong for a dozen. An option may carry a tone from the shared status set, applied only while that option is selected:
{ id: "status", label: "Status", kind: "pills", options: [ { value: "healthy", label: "Healthy", tone: "ok" }, { value: "failing", label: "Failing", tone: "down" }, ],}Reserve tone for a dimension that genuinely IS a status. On a health surface green and red are the product’s vocabulary, and a selected “Failing” that looks identical to a selected “Healthy” throws that away. Leave it unset for everything else - a neutral selected state is right for “enabled/disabled”, and colouring those dilutes the tones that mean something. Tone is presentation only; it never affects matching.
Disabling a facet
Section titled “Disabling a facet”A dimension whose data source is not installed yet keeps its control, disabled, with a reason:
{ id: "health", label: "Health", options: HEALTH_OPTIONS, disabled: !healthEnabled, disabledReason: "Health filtering becomes available once a health source is installed", value: (system) => resolveHealth(system),}Prefer this to dropping the facet from the array. A present-but-unavailable control says the capability exists and what would unlock it; an absent one says nothing. Disabling also keeps the parameter declared, so a selection arriving on a shared link is preserved - and it still constrains, because disabling stops the operator changing the selection, not the selection itself.
Facets the table cannot apply
Section titled “Facets the table cannot apply”A surface sometimes owns a control the facet model cannot apply: a row that belongs to several groups or carries several tags has no single value, and one bar may filter two different row types at once (the catalog’s toolbar narrows both systems and groups). Such a surface passes DataTableFacetControls - a facet without the row accessor - and keeps its own matching:
const controls: DataTableFacetControl[] = [ { id: "group", label: "Group", anyLabel: "All groups", options: groupOptions }, { id: "tag", label: "Tag", anyLabel: "All tags", options: tagOptions },];
<DataTableFilterBar filters={filters.state} onFiltersChange={filters.setState} onClear={filters.clear} facets={controls}/>;Read the selections back with parsedFacetValue (or filters.debounced.facets) and apply them in your own .logic.ts matcher. A DataTableFacet<TData> is a control, so a table’s facets keep working with the bar unchanged. Reach for this only when the accessor genuinely cannot be written - a single-valued dimension belongs in facets, applied by the table.
Where the filter state lives
Section titled “Where the filter state lives”By default the table owns the state internally, which is right for a simple list. Reach for useDataTableFilters when the state has to be observable:
const filters = useDataTableFilters({ facetIds: ["status", "severity"] });
<DataTable data={rows} columns={columns} getRowId={(r) => r.id} facets={facets} filters={filters.state} onFiltersChange={filters.setState} onClearFilters={filters.clear}/>;The hook persists to the URL, so a filtered view is shareable, survives a reload, and comes back intact after following a row into its detail page. It also hands the page filters.active, which is what you need to gate a control that a filtered view makes ambiguous - reorder arrows, for instance, whose neighbour may be hidden.
Pass paramPrefix when a page has two filtered tables, so they do not fight over q. Use filters.debounced when you run the filtering (a server-side query input, or a list that is not a DataTable); a plain table wants filters.state, since it debounces internally.
For a list surface that is not a table at all, render DataTableFilterBar directly with the same state, so a card grid filters identically to a table. Its children slot takes controls that belong in that row but are not row filters - a density toggle, a date range - so they stay beside the filters instead of forming a second bar.
Surface and toolbar
Section titled “Surface and toolbar”The table is wrapped in an opaque, bordered bg-card panel by default, so it stays readable over any page background. Pass surface={false} when it is nested inside a page’s own opaque Card - that both drops the panel-in-panel and insets the filter bar with a separating rule, so a full-bleed table’s controls are not flush against the card’s edges.
Use toolbar for ACTIONS beside the filters - an “Add” button, an export. It is right-aligned, away from the controls.
Use filterExtras for a CONTROL that belongs with the filters but is not a facet: a date range, a density toggle, or a boolean that WIDENS the list (“Show resolved”) and so cannot be a facet, since a facet narrows. It renders inside the filter row, next to the facets. Putting such a control in toolbar strands it at the far edge of the page, reading as unrelated to the filters it belongs with.