Data Table
betaRecords in rows and columns that sort, select, page and open, with column widths that never move.
@zenoxai/ui/patterns/data-table Markdown| Asset | ||||
|---|---|---|---|---|
Ransomware note dropped on a file share | Critical | Open | fs-prod-02.corp.acme.com | Oct 24, 2026, 2:57:26 PM |
Leaked API key in a public repository | Critical | Open | github.com/acme/infra | Oct 24, 2026, 1:22:08 PM |
Outbound traffic to a known C2 domain | High | Open | wks-0117.corp.acme.com | Oct 24, 2026, 2:52:11 PM |
Credential stuffing against the VPN | High | Acknowledged | vpn.acme.com | Oct 24, 2026, 2:48:03 PM |
New admin added outside the change window | Medium | Open | okta-tenant-prod | Oct 24, 2026, 2:31:40 PM |
Expired TLS certificate on a public host | Medium | Acknowledged | status.acme.com | Oct 24, 2026, 1:59:12 PM |
Port 3389 exposed to the internet | Low | Resolved | 203.0.113.18 | Oct 24, 2026, 1:41:55 PM |
function DataTableDemo() {
type Alert = {
id: string
title: string
severity: "critical" | "high" | "medium" | "low"
asset: string
status: "Open" | "Acknowledged" | "Resolved"
detected: string
}
const alerts: Alert[] = [
{
id: "ALR-4821",
title: "Ransomware note dropped on a file share",
severity: "critical",
asset: "fs-prod-02.corp.acme.com",
status: "Open",
detected: "2026-10-24T14:57:26Z",
},
{
id: "ALR-4820",
title: "Outbound traffic to a known C2 domain",
severity: "high",
asset: "wks-0117.corp.acme.com",
status: "Open",
detected: "2026-10-24T14:52:11Z",
},
{
id: "ALR-4819",
title: "Credential stuffing against the VPN",
severity: "high",
asset: "vpn.acme.com",
status: "Acknowledged",
detected: "2026-10-24T14:48:03Z",
},
{
id: "ALR-4818",
title: "New admin added outside the change window",
severity: "medium",
asset: "okta-tenant-prod",
status: "Open",
detected: "2026-10-24T14:31:40Z",
},
{
id: "ALR-4817",
title: "Expired TLS certificate on a public host",
severity: "medium",
asset: "status.acme.com",
status: "Acknowledged",
detected: "2026-10-24T13:59:12Z",
},
{
id: "ALR-4816",
title: "Port 3389 exposed to the internet",
severity: "low",
asset: "203.0.113.18",
status: "Resolved",
detected: "2026-10-24T13:41:55Z",
},
{
id: "ALR-4815",
title: "Leaked API key in a public repository",
severity: "critical",
asset: "github.com/acme/infra",
status: "Open",
detected: "2026-10-24T13:22:08Z",
},
]
const columns = React.useMemo(() => {
const status = {
Open: { tone: "danger", icon: CircleAlertIcon },
Acknowledged: { tone: "warning", icon: EyeIcon },
Resolved: { tone: "success", icon: CircleCheckIcon },
} as const
const time = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "medium",
timeZone: "UTC",
})
const column = createColumnHelper<Alert>()
return [
column.accessor("title", {
header: "Alert",
meta: { fluid: true, minWidth: 260 },
cell: (info) => <DataTableText strong>{info.getValue()}</DataTableText>,
}),
column.accessor("severity", {
header: "Severity",
size: 110,
sortFn: sortByRank(["critical", "high", "medium", "low"]),
cell: (info) => <SeverityBadge level={info.getValue()} />,
}),
column.accessor("status", {
header: "Status",
size: 150,
sortFn: sortByRank(["Open", "Acknowledged", "Resolved"]),
cell: (info) => (
<StatusBadge
tone={status[info.getValue()].tone}
icon={status[info.getValue()].icon}
label={info.getValue()}
/>
),
}),
column.accessor("asset", {
header: "Asset",
size: 210,
enableSorting: false,
// Narrow tables drop the context columns before the deciding ones.
meta: { hideBelow: 760 },
cell: (info) => <DataTableText mono>{info.getValue()}</DataTableText>,
}),
column.accessor("detected", {
header: "Detected",
size: 190,
sortDescFirst: true,
meta: {
hideBelow: 960,
description: "Time of the first event, in UTC.",
},
cell: (info) => (
<DataTableText numeric>
{time.format(new Date(info.getValue()))}
</DataTableText>
),
}),
]
}, [])
const table = useDataTable({
data: alerts,
columns,
getRowId: (alert) => alert.id,
// Triage: the most severe first, then the newest.
defaultSorting: [
{ id: "severity", desc: false },
{ id: "detected", desc: true },
],
})
return (
<div className="w-full overflow-hidden rounded-lg border border-border">
<DataTable table={table} label="Open alerts" />
</div>
)
}Usage
import {
DataTable,
DataTableText,
createColumnHelper,
useDataTable,
} from "@zenoxai/ui/patterns/data-table"
const column = createColumnHelper<Alert>()
const columns = [
column.accessor("title", {
header: "Alert",
meta: { fluid: true },
cell: (info) => <DataTableText strong>{info.getValue()}</DataTableText>,
}),
column.accessor("asset", {
header: "Asset",
size: 200,
cell: (info) => <DataTableText mono>{info.getValue()}</DataTableText>,
}),
]
export function AlertList({ alerts }: { alerts: Alert[] }) {
const table = useDataTable({
data: alerts,
columns,
getRowId: (alert) => alert.id,
})
return <DataTable table={table} label="Open alerts" />
}
Use Data Table for any list of records people work through: alerts, events, assets, findings,
users, keys. It is built on TanStack Table 9 and TanStack Virtual,
which the package ships; import every type from @zenoxai/ui/patterns/data-table
(DataTableColumnDef, DataTableInstance, DataTableRow, SortingState and the other state
types), never from TanStack. For a small static table with no sorting, selection or paging, use
Table. For records with images or several lines of text, use an
Item list. Decide which columns to show and in what order with the
tables guideline.
- Two parts.
useDataTableholds the rows and the state (sort, filters, search, selection, hidden columns, page), each part controlled or uncontrolled;DataTablerenders it. The toolbar around the table (search, facets, the Columns menu, a bulk action bar) takes the sametable. For four or more filters, put the table in a Filter panel and passtotal: the table announces the result count when the rows settle after a change. - Widths come from the columns, never from the content. Give every column a
sizein px and mark exactly one columnmeta: { fluid: true }, usually the one that names the row: it takes the remaining width, never less thanminWidth(240px by default). Below the sum, the table scrolls sideways;selectColumn()andactionsColumn()pin themselves, and the fluid column can pin to the start withmeta.sticky. - One or two lines. Rows are 36px with one line of text, 52px with
lines={2}, for a value with a qualifier under it (Data Table Text withsecondary). Cells never wrap; the console warns about a row taller than its line count. - Who sorts, filters and pages.
mode: "client"(the default) does it in the browser, with facet counts.mode: "server"reportsonSortingChange,onColumnFiltersChange,onGlobalFilterChangeandonPaginationChangefor your query; pass the total asrowCount. In server mode a column sorts only withenableSorting: true. A header toggles ascending and descending; there is no unsorted state and no multi-sort. A default sort on two columns goes indefaultSorting. Empty values (null, undefined, “”) sort last in both directions, with your ownsortFntoo. - Paging is opt-in. Pass
defaultPageSize(client) orpagination(controlled) to show the footer with the range and the page sizes (pageSizeOptions, 25, 50 and 100 by default). Without either, every row shows in one list. With controlled pagination, reset the page in the same update that changes the sort or the filters. - Height.
height="fill"fills a parent with a bounded height (a flex column) and scrolls inside, under a sticky header; a number scrolls from that height. The default,auto, is for short tables: it never virtualizes and its header does not stick. - Selection.
selectColumn()adds the checkboxes.selectionScopesays what a selection survives:"page"(the default in server mode) clears it on a new page, sort or filter;"persist"(the default in client mode) keeps it. - Texts. Every text the table renders is a prop in
labels, in English by default, with numbers formatted as en-US. Pass your translations and formatters. - Define columns once, outside the component or in a
useMemo. A new array re-renders every row; that is also how cells pick up outside state.
Column options
Set these in a column’s meta:
| Option | What it does |
|---|---|
fluid |
The column takes the width the others leave. Exactly one per table; it cannot be hidden. |
minWidth |
The fluid column’s minimum width, in px. Defaults to 240. |
align |
"start", "end" or "center", for the header and the cells. Numbers people compare: "end". |
label |
The column’s name in the Columns menu, when header is not a plain string. |
description |
Help behind an info icon after the header: what the column measures, its unit or time zone. |
sticky |
"start" or "end": pins the column while the table scrolls sideways. |
hideBelow |
Hides the column while the table’s container is narrower than this, in px. |
className |
Classes for the column’s body cells. |
Besides meta, the column definition takes TanStack’s own options: size, enableSorting,
sortDescFirst (time and score columns: newest or highest first), sortFn (sortByRank for
severity, status and priority), filterFn (isAnyOf() for facets) and enableHiding.
Examples
Two lines, a score and a severity accent
2,000 indicators: the table renders only the rows in view. The type sits under each indicator, the time under each date, and a bar on the leading edge repeats the confidence the row shows as a number, on the same scale as the score’s color. Confidence has help in its header.
| Tags |
|---|
function DataTableTwoLines() {
type Indicator = {
id: string
value: string
type: "Domain" | "IP:Port" | "Malware file"
threat: string
confidence: number
tags: readonly string[]
malware: string | null
firstSeen: [string, string]
}
const indicators = React.useMemo<Indicator[]>(() => {
const families = [
{
threat: "Payload delivery",
malware: "StrelaStealer",
tags: ["strelastealer"],
},
{ threat: "Botnet C2", malware: "Amadey", tags: ["viriback", "amadey"] },
{ threat: "Payload", malware: "Mirai", tags: [] },
{ threat: "Botnet C2", malware: null, tags: ["chaos", "drb-ra", "c2"] },
{
threat: "Payload delivery",
malware: "php.shin_webshell",
tags: ["cloudflare", "wordpress", "webshell"],
},
]
const words = ["cdn", "update", "secure", "login", "files", "static"]
return Array.from({ length: 2000 }, (_, index) => {
const family = families[index % families.length]!
const type = (["Domain", "IP:Port", "Malware file"] as const)[index % 3]!
const value =
type === "Domain"
? `${words[index % words.length]}-${index}[.]${words[(index + 2) % words.length]}-check[.]com`
: type === "IP:Port"
? `${["192[.]0[.]2", "198[.]51[.]100", "203[.]0[.]113"][index % 3]}[.]${((index * 7) % 254) + 1}:${[80, 443, 8080][index % 3]}`
: (index * 2654435761)
.toString(16)
.padStart(8, "0")
.repeat(5)
.slice(0, 40)
return {
id: `ioc-${index + 1}`,
value,
type,
threat: family.threat,
confidence: [100, 75, 50, 100, 25][index % 5]!,
tags: family.tags,
malware: family.malware,
firstSeen: [
`Oct ${24 - (index % 20)}, 2026`,
`${String(23 - (index % 24)).padStart(2, "0")}:${String(59 - (index % 60)).padStart(2, "0")}:26`,
],
}
})
}, [])
// One scale for the score's tone and the row's accent.
const confidenceTone = (value: number) =>
value >= 100 ? "high" : value >= 75 ? "medium" : "neutral"
const columns = React.useMemo(() => {
const typeIcon = {
Domain: <GlobeIcon />,
"IP:Port": <ServerIcon />,
"Malware file": <FileCodeIcon />,
}
const column = createColumnHelper<Indicator>()
return [
column.accessor("value", {
header: "Indicator",
meta: { fluid: true, minWidth: 280 },
cell: ({ row }) => (
<DataTableText
mono
strong
icon={typeIcon[row.original.type]}
secondary={row.original.type}
>
{row.original.value}
</DataTableText>
),
}),
column.accessor("confidence", {
header: "Confidence",
size: 150,
sortDescFirst: true,
meta: {
description:
"How sure the source is that the indicator is malicious, from 0 to 100.",
},
cell: (info) => (
<DataTableScore
value={info.getValue()}
tone={confidenceTone(info.getValue())}
/>
),
}),
column.accessor("threat", {
header: "Threat type",
size: 150,
cell: (info) => <DataTableText>{info.getValue()}</DataTableText>,
}),
column.accessor("tags", {
header: "Tags",
size: 210,
enableSorting: false,
cell: (info) => <DataTableTags items={info.getValue()} />,
}),
column.accessor("malware", {
header: "Malware",
size: 150,
cell: (info) => <DataTableText>{info.getValue()}</DataTableText>,
}),
column.accessor((indicator) => indicator.firstSeen.join(" "), {
id: "firstSeen",
header: "First seen",
size: 130,
cell: ({ row }) => (
<DataTableText numeric secondary={row.original.firstSeen[1]}>
{row.original.firstSeen[0]}
</DataTableText>
),
}),
]
}, [])
const table = useDataTable({
data: indicators,
columns,
getRowId: (indicator) => indicator.id,
})
return (
<div className="w-full overflow-hidden rounded-lg border border-border">
<DataTable
table={table}
label="Indicators of compromise"
lines={2}
height={420}
getRowAccent={(indicator) => {
const tone = confidenceTone(indicator.confidence)
return tone === "neutral" ? undefined : tone
}}
/>
</div>
)
}Header dividers
Open findings per asset, one narrow column per severity: headerDividers draws a rule between
the header cells, so each count reads under its own header. The body keeps no vertical rules; the
end alignment separates the columns. Leave them off in tables with few, wide columns.
bastion-01.acme.com | 2 | 5 | 9 | 14 |
files.acme.com | 1 | 3 | 6 | 11 |
vpn.acme.com | 1 | 0 | 4 | 7 |
status.acme.com | 0 | 2 | 8 | 3 |
www.acme.com | 0 | 1 | 5 | 12 |
mail.acme.com | 0 | 1 | 3 | 6 |
ci.acme.com | 0 | 0 | 2 | 9 |
wiki.acme.com | 0 | 0 | 1 | 4 |
function DataTableHeaderDividers() {
type Level = "critical" | "high" | "medium" | "low"
type AssetCounts = { id: string } & Record<Level, number>
const assets: AssetCounts[] = [
{ id: "bastion-01.acme.com", critical: 2, high: 5, medium: 9, low: 14 },
{ id: "files.acme.com", critical: 1, high: 3, medium: 6, low: 11 },
{ id: "vpn.acme.com", critical: 1, high: 0, medium: 4, low: 7 },
{ id: "status.acme.com", critical: 0, high: 2, medium: 8, low: 3 },
{ id: "www.acme.com", critical: 0, high: 1, medium: 5, low: 12 },
{ id: "mail.acme.com", critical: 0, high: 1, medium: 3, low: 6 },
{ id: "ci.acme.com", critical: 0, high: 0, medium: 2, low: 9 },
{ id: "wiki.acme.com", critical: 0, high: 0, medium: 1, low: 4 },
]
const columns = React.useMemo(() => {
const column = createColumnHelper<AssetCounts>()
// Four counts side by side, the same width each: wide enough for the
// longest header with its sort arrow.
const count = (level: Level, header: string) =>
column.accessor(level, {
header,
size: 96,
sortDescFirst: true,
meta: { align: "end" },
cell: (info) => (
<DataTableText numeric>{info.getValue()}</DataTableText>
),
})
return [
column.accessor("id", {
header: "Asset",
meta: { fluid: true, minWidth: 200 },
cell: (info) => (
<DataTableText mono strong>
{info.getValue()}
</DataTableText>
),
}),
count("critical", "Critical"),
count("high", "High"),
count("medium", "Medium"),
count("low", "Low"),
]
}, [])
const table = useDataTable({
data: assets,
columns,
getRowId: (asset) => asset.id,
defaultSorting: [{ id: "critical", desc: true }],
})
return (
<div className="w-full overflow-hidden rounded-lg border border-border">
<DataTable table={table} label="Open findings by asset" headerDividers />
</div>
)
}Selection, filters and columns
A selection column, row actions with actionsColumn(), search through globalFilter, two facets
with their counts (column.getFacetedUniqueValues(), filtered with isAnyOf()), chips for the
active filters and the Columns menu. With rows selected, the toolbar turns into the bulk action
bar. “Last scan” hides when the table is narrower than 900px (meta.hideBelow).
| Actions | |||||||
|---|---|---|---|---|---|---|---|
fs-prod-02.corp.acme.com | 10.20.4.12 | Infrastructure | Internal | 14 | Oct 24, 2026 09:12:40 | ||
vpn.acme.com | 198.51.100.23 | Network | Internet-facing | 3 | Oct 24, 2026 08:55:02 | ||
wks-0117.corp.acme.com | 10.20.9.117 | No owner | Internal | 27 | Oct 23, 2026 22:41:19 | ||
bastion-01.acme.com | 203.0.113.4 | Security | Internet-facing | 0 | Oct 24, 2026 07:30:00 | ||
status.acme.com | 203.0.113.18 | Web | Internet-facing | 1 | Oct 22, 2026 18:03:51 | ||
db-analytics-03.corp.acme.com | 10.20.6.33 | Data | Internal | 9 | Oct 24, 2026 06:14:27 | ||
mail.acme.com | 203.0.113.25 | Infrastructure | Internet-facing | 5 | Oct 24, 2026 05:48:12 |
function DataTableSelection() {
type Asset = {
id: string
hostname: string
ip: string
owner: string | null
exposure: "Internet-facing" | "Internal"
openFindings: number
lastScan: string
}
const assets: Asset[] = [
{
id: "a1",
hostname: "fs-prod-02.corp.acme.com",
ip: "10.20.4.12",
owner: "Infrastructure",
exposure: "Internal",
openFindings: 14,
lastScan: "Oct 24, 2026 09:12:40",
},
{
id: "a2",
hostname: "vpn.acme.com",
ip: "198.51.100.23",
owner: "Network",
exposure: "Internet-facing",
openFindings: 3,
lastScan: "Oct 24, 2026 08:55:02",
},
{
id: "a3",
hostname: "wks-0117.corp.acme.com",
ip: "10.20.9.117",
owner: null,
exposure: "Internal",
openFindings: 27,
lastScan: "Oct 23, 2026 22:41:19",
},
{
id: "a4",
hostname: "bastion-01.acme.com",
ip: "203.0.113.4",
owner: "Security",
exposure: "Internet-facing",
openFindings: 0,
lastScan: "Oct 24, 2026 07:30:00",
},
{
id: "a5",
hostname: "status.acme.com",
ip: "203.0.113.18",
owner: "Web",
exposure: "Internet-facing",
openFindings: 1,
lastScan: "Oct 22, 2026 18:03:51",
},
{
id: "a6",
hostname: "db-analytics-03.corp.acme.com",
ip: "10.20.6.33",
owner: "Data",
exposure: "Internal",
openFindings: 9,
lastScan: "Oct 24, 2026 06:14:27",
},
{
id: "a7",
hostname: "mail.acme.com",
ip: "203.0.113.25",
owner: "Infrastructure",
exposure: "Internet-facing",
openFindings: 5,
lastScan: "Oct 24, 2026 05:48:12",
},
]
const columns = React.useMemo(() => {
const column = createColumnHelper<Asset>()
return [
selectColumn<Asset>({ getRowLabel: (asset) => asset.hostname }),
column.accessor("hostname", {
header: "Hostname",
meta: { fluid: true, minWidth: 240 },
cell: (info) => (
<DataTableText mono strong>
{info.getValue()}
</DataTableText>
),
}),
column.accessor("ip", {
header: "IP address",
size: 140,
cell: (info) => <DataTableText mono>{info.getValue()}</DataTableText>,
}),
column.accessor("owner", {
header: "Owner",
size: 140,
filterFn: isAnyOf(),
cell: (info) => (
<DataTableText emptyLabel="No owner">{info.getValue()}</DataTableText>
),
}),
column.accessor("exposure", {
header: "Exposure",
size: 140,
filterFn: isAnyOf(),
cell: (info) => <DataTableText>{info.getValue()}</DataTableText>,
}),
column.accessor("openFindings", {
header: "Open findings",
size: 150,
meta: {
align: "end",
description:
"Findings not resolved or accepted, across every scanner.",
},
cell: (info) => (
<DataTableText numeric>{info.getValue()}</DataTableText>
),
}),
column.accessor("lastScan", {
header: "Last scan",
size: 170,
meta: {
hideBelow: 900,
description: "The last completed scan, in your time zone.",
},
cell: (info) => (
<DataTableText numeric>{info.getValue()}</DataTableText>
),
}),
actionsColumn<Asset>({
cell: (asset) => (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={`Actions for ${asset.hostname}`}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Scan now</DropdownMenuItem>
<DropdownMenuItem>Assign owner</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}),
]
}, [])
const table = useDataTable({
data: assets,
columns,
getRowId: (asset) => asset.id,
})
const selected = Object.keys(table.state.rowSelection).length
// A facet: the column's values with their counts among the other filters'
// results, as checkboxes that set the column filter.
const facet = (columnId: "owner" | "exposure", label: string) => {
const column = table.getColumn(columnId)
const values = (column?.getFilterValue() as unknown[] | undefined) ?? []
const counts =
column?.getFacetedUniqueValues() ?? new Map<unknown, number>()
const name = (value: unknown) =>
value === null ? "No owner" : String(value)
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
<ListFilterIcon data-icon="inline-start" />
{label}
{values.length > 0 ? (
<span className="text-text-secondary tabular-nums">
{values.length}
</span>
) : null}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{[...counts.keys()]
.sort((a, b) => name(a).localeCompare(name(b)))
.map((value) => (
<DropdownMenuCheckboxItem
key={name(value)}
indicator="checkbox"
checked={values.includes(value)}
closeOnClick={false}
onCheckedChange={(checked) =>
column?.setFilterValue(
checked
? [...values, value]
: values.filter((item) => item !== value)
)
}
>
<span className="truncate">{name(value)}</span>
<span className="ms-auto ps-3 text-xs text-text-secondary tabular-nums">
{counts.get(value)}
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
const activeFilters = table.state.columnFilters
return (
<div className="w-full overflow-hidden rounded-lg border border-border">
<div className="flex flex-col gap-2 border-b border-border p-2">
<div className="flex flex-wrap items-center gap-2">
{selected > 0 ? (
<>
<span className="px-1 text-sm font-medium">
{selected} selected
</span>
<Button variant="outline" size="sm">
Scan now
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => table.resetRowSelection()}
>
Clear selection
</Button>
</>
) : (
<>
<InputGroup className="w-56" size="sm">
<InputGroupInput
type="search"
value={table.state.globalFilter ?? ""}
onChange={(event) =>
table.setGlobalFilter(event.target.value)
}
placeholder="Search assets"
aria-label="Search assets"
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
</InputGroup>
{facet("owner", "Owner")}
{facet("exposure", "Exposure")}
</>
)}
<DataTableColumnsMenu table={table} className="ms-auto" />
</div>
{activeFilters.length > 0 && selected === 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{activeFilters.map((filter) => (
<FilterChip
key={filter.id}
label={filter.id === "owner" ? "Owner" : "Exposure"}
value={(filter.value as unknown[])
.map((value) => (value === null ? "No owner" : String(value)))
.join(", ")}
removeAriaLabel={`Clear the ${filter.id} filter`}
onRemove={() =>
table.getColumn(filter.id)?.setFilterValue(undefined)
}
/>
))}
<Button
variant="ghost"
size="xs"
onClick={() => table.resetColumnFilters()}
>
Clear filters
</Button>
</div>
) : null}
</div>
<DataTable
table={table}
label="Assets"
emptyState={
<ListEmptyState
icon={SearchIcon}
title="No asset matches the filters"
description="Change the search or clear the filters."
/>
}
/>
</div>
)
}Rows that open
onRowClick opens the record in place, such as in a sheet, and activeRowId marks the open
row. Clicks on links, buttons, checkboxes and menus inside the row, and text selections, do not
count. The identity cell is a button (DataTableText with onClick): the keyboard path to the
row. For a record with a URL, pass getRowHref and render the link in the identity cell
(href, or render with your router’s link): a click anywhere on the row follows it, and
Cmd/Ctrl+click or a middle click opens it in a new tab.
| Critical | bastion-01.acme.com | |
| Medium | status.acme.com | |
| High | files.acme.com | |
| Low | www.acme.com |
Select a finding to open it.
function DataTableOpenRows() {
type Finding = {
id: string
title: string
asset: string
severity: "critical" | "high" | "medium" | "low"
}
const findings: Finding[] = [
{
id: "FND-2207",
title: "OpenSSH 8.2 vulnerable to CVE-2024-6387",
asset: "bastion-01.acme.com",
severity: "critical",
},
{
id: "FND-2206",
title: "TLS 1.0 enabled",
asset: "status.acme.com",
severity: "medium",
},
{
id: "FND-2205",
title: "Directory listing on /backup",
asset: "files.acme.com",
severity: "high",
},
{
id: "FND-2204",
title: "Missing HSTS header",
asset: "www.acme.com",
severity: "low",
},
]
const [openId, setOpenId] = React.useState<string>()
const columns = React.useMemo(() => {
const column = createColumnHelper<Finding>()
return [
column.accessor("title", {
header: "Finding",
meta: { fluid: true },
// The identity is a button: the keyboard path to open the row.
cell: ({ row }) => (
<DataTableText strong onClick={() => setOpenId(row.original.id)}>
{row.original.title}
</DataTableText>
),
}),
column.accessor("severity", {
header: "Severity",
size: 110,
sortFn: sortByRank(["critical", "high", "medium", "low"]),
cell: (info) => <SeverityBadge level={info.getValue()} />,
}),
column.accessor("asset", {
header: "Asset",
size: 200,
cell: (info) => <DataTableText mono>{info.getValue()}</DataTableText>,
}),
]
}, [])
const table = useDataTable({
data: findings,
columns,
getRowId: (finding) => finding.id,
})
const open = findings.find((finding) => finding.id === openId)
return (
<div className="flex w-full flex-col gap-3">
<div className="overflow-hidden rounded-lg border border-border">
<DataTable
table={table}
label="Findings"
onRowClick={(finding) => setOpenId(finding.id)}
activeRowId={openId}
/>
</div>
<p className="text-sm text-text-secondary" aria-live="polite">
{open
? `Open in the detail view: ${open.id}, ${open.title}`
: "Select a finding to open it."}
</p>
</div>
)
}Sorted and paged on the server
mode: "server" with controlled sorting and pagination and the total in rowCount. The
sort handler resets the page in the same update: two separate callbacks would race in a URL
store.
| User | Source |
|---|
function DataTableServerPaging() {
type AuditEvent = {
id: string
name: string
source: string
user: string
at: string
}
// Stands in for the API: 1,204 audit events, sorted and paged on the server.
const all = React.useMemo<AuditEvent[]>(
() =>
Array.from({ length: 1204 }, (_, index) => ({
id: `evt-${index + 1}`,
name: [
"User signed in",
"API key created",
"Rule disabled",
"Export downloaded",
][index % 4]!,
source: ["Okta", "Console", "API", "Console"][index % 4]!,
user: ["m.rocha@acme.com", "j.lima@acme.com", "svc-ci@acme.com"][
index % 3
]!,
at: new Date(
Date.UTC(2026, 9, 24, 23, 59) - index * 17 * 60_000
).toISOString(),
})),
[]
)
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "at", desc: true },
])
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
})
// The page the server returned, and the query it answers.
const query = JSON.stringify({ sorting, pagination })
const [result, setResult] = React.useState<{
query: string
rows: AuditEvent[]
} | null>(null)
React.useEffect(() => {
const timer = setTimeout(() => {
const [sort] = sorting
const sorted = sort
? [...all].sort((a, b) => {
const key = sort.id as keyof AuditEvent
const order = a[key].localeCompare(b[key])
return sort.desc ? -order : order
})
: all
const start = pagination.pageIndex * pagination.pageSize
setResult({
query,
rows: sorted.slice(start, start + pagination.pageSize),
})
}, 400)
return () => clearTimeout(timer)
}, [all, sorting, pagination, query])
const page = result?.rows ?? []
const loading = result?.query !== query
const columns = React.useMemo(() => {
const time = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "medium",
timeZone: "UTC",
})
const column = createColumnHelper<AuditEvent>()
return [
column.accessor("at", {
header: "Time",
size: 190,
enableSorting: true,
sortDescFirst: true,
meta: { description: "In UTC." },
cell: (info) => (
<DataTableText numeric>
{time.format(new Date(info.getValue()))}
</DataTableText>
),
}),
column.accessor("name", {
header: "Event",
meta: { fluid: true },
enableSorting: true,
cell: (info) => <DataTableText strong>{info.getValue()}</DataTableText>,
}),
column.accessor("user", {
header: "User",
size: 200,
cell: (info) => <DataTableText mono>{info.getValue()}</DataTableText>,
}),
column.accessor("source", { header: "Source", size: 120 }),
]
}, [])
const table = useDataTable({
data: page,
columns,
getRowId: (event) => event.id,
mode: "server",
rowCount: all.length,
sorting,
// The owner of the page resets it in the same update as the sort.
onSortingChange: (next) => {
setSorting(next)
setPagination((current) => ({ ...current, pageIndex: 0 }))
},
pagination,
onPaginationChange: setPagination,
})
return (
<div className="w-full overflow-hidden rounded-lg border border-border">
<DataTable
table={table}
label="Audit events"
height={360}
loading={loading && page.length === 0}
fetching={loading && page.length > 0}
/>
</div>
)
}A list that grows
For cursor-based APIs, in a table that scrolls inside. The next rows load when the end of the
list comes into view, and a “Load more” button keeps the list reachable from the keyboard.
Loading on its own stops after 1,000 rows (autoLoadLimit) or when a load brings nothing new.
| Credential | Source | Found |
|---|---|---|
user1@acme.com | Combolist | Oct 24, 2026 |
user2@acme.com | Stealer log | Oct 24, 2026 |
user3@acme.com | Paste site | Oct 24, 2026 |
user4@acme.com | Combolist | Oct 24, 2026 |
user5@acme.com | Stealer log | Oct 24, 2026 |
user6@acme.com | Paste site | Oct 24, 2026 |
user7@acme.com | Combolist | Oct 24, 2026 |
user8@acme.com | Stealer log | Oct 24, 2026 |
user9@acme.com | Paste site | Oct 24, 2026 |
user10@acme.com | Combolist | Oct 24, 2026 |
user11@acme.com | Stealer log | Oct 24, 2026 |
user12@acme.com | Paste site | Oct 24, 2026 |
user13@acme.com | Combolist | Oct 24, 2026 |
user14@acme.com | Stealer log | Oct 24, 2026 |
user15@acme.com | Paste site | Oct 24, 2026 |
user16@acme.com | Combolist | Oct 24, 2026 |
user17@acme.com | Stealer log | Oct 24, 2026 |
user18@acme.com | Paste site | Oct 24, 2026 |
user19@acme.com | Combolist | Oct 24, 2026 |
user20@acme.com | Stealer log | Oct 24, 2026 |
user21@acme.com | Paste site | Oct 24, 2026 |
user22@acme.com | Combolist | Oct 24, 2026 |
user23@acme.com | Stealer log | Oct 24, 2026 |
user24@acme.com | Paste site | Oct 24, 2026 |
user25@acme.com | Combolist | Oct 24, 2026 |
function DataTableGrowing() {
type Leak = {
id: string
email: string
source: string
found: string
}
const makeLeaks = (from: number, count: number): Leak[] =>
Array.from({ length: count }, (_, index) => ({
id: `leak-${from + index + 1}`,
email: `user${from + index + 1}@acme.com`,
source: ["Combolist", "Stealer log", "Paste site"][(from + index) % 3]!,
found: `Oct ${24 - Math.floor((from + index) / 30)}, 2026`,
}))
const total = 180
const [rows, setRows] = React.useState<Leak[]>(() => makeLeaks(0, 25))
const [loadingMore, setLoadingMore] = React.useState(false)
const loadMore = () => {
setLoadingMore(true)
setTimeout(() => {
setRows((current) =>
[...current, ...makeLeaks(current.length, 25)].slice(0, total)
)
setLoadingMore(false)
}, 600)
}
const columns = React.useMemo(() => {
const column = createColumnHelper<Leak>()
return [
column.accessor("email", {
header: "Credential",
meta: { fluid: true },
cell: (info) => (
<DataTableText mono strong>
{info.getValue()}
</DataTableText>
),
}),
column.accessor("source", { header: "Source", size: 140 }),
column.accessor("found", {
header: "Found",
size: 140,
cell: (info) => (
<DataTableText numeric>{info.getValue()}</DataTableText>
),
}),
]
}, [])
const table = useDataTable({
data: rows,
columns,
getRowId: (leak) => leak.id,
mode: "server",
rowCount: total,
})
return (
<div className="w-full overflow-hidden rounded-lg border border-border">
<DataTable
table={table}
label="Leaked credentials"
height={320}
onLoadMore={loadMore}
hasMore={rows.length < total}
loadingMore={loadingMore}
/>
</div>
)
}Loading, empty and error
Skeleton rows keep the final column widths and fill the view without scrolling it. The empty and error states are centered in the visible area. An error with rows keeps them and says what failed under them.
function DataTableStates() {
type Finding = { id: string; title: string; asset: string }
const [state, setState] = React.useState("loading")
const columns = React.useMemo(() => {
const column = createColumnHelper<Finding>()
return [
column.accessor("title", { header: "Finding", meta: { fluid: true } }),
column.accessor("asset", { header: "Asset", size: 220 }),
]
}, [])
const table = useDataTable({
data: [],
columns,
getRowId: (finding) => finding.id,
})
return (
<div className="flex w-full flex-col gap-3">
<ToggleGroup
variant="segmented"
size="sm"
value={[state]}
onValueChange={(value) => {
if (value[0]) setState(value[0])
}}
aria-label="Table state"
>
<ToggleGroupItem value="loading">Loading</ToggleGroupItem>
<ToggleGroupItem value="empty">Empty</ToggleGroupItem>
<ToggleGroupItem value="error">Error</ToggleGroupItem>
</ToggleGroup>
<div className="overflow-hidden rounded-lg border border-border">
<DataTable
table={table}
label="Findings"
height={260}
loading={state === "loading"}
error={state === "error"}
onRetry={() => setState("loading")}
emptyState={
<ListEmptyState
title="No findings in the last 30 days"
description="New findings appear here as scans complete."
/>
}
/>
</div>
</div>
)
}Accessibility
- The table is a native
<table>named bylabel. Header cells areth scope="col"; the sorted one carriesaria-sort. The header’s label is the sort button; the arrow after the help icon sorts on click too and stays out of the accessibility tree. - When the table scrolls, its container is a named region that takes keyboard focus, so it can be scrolled with the arrow keys.
- Paged, virtualized and server tables set
aria-rowcount(-1while the total of a growing list is unknown) andaria-rowindex, so screen readers announce a row’s position in the whole list. Off-screen rows of a virtualized table are not in the page: browser find does not reach them. - The keyboard path to a row is the link or button in its identity cell. Use
focusableRowsonly for rows with no control; they then open with Enter. - Row checkboxes are named after the row (
getRowLabel), and the header checkbox reports a partial selection. A row’s actions button needs anaria-labelthat names the row. - The “+N” counter of Data Table Tags takes focus and names the hidden tags.
- The severity accent repeats a value the row shows as text, and the active row has an outline besides its color.
API reference
Generated from the TypeScript source. className, style and children are accepted everywhere and omitted below.
DataTableScore
DataTableScore shows a score in a DataTable cell: the number, which is the value people read and compare, with a thin bar under it for a quick scan down the column. A severity tone colors both; the number always shows, so the color is never the only signal.
| Prop | Type | Default |
|---|---|---|
emptyLabel | stringAlready-translated label Empty Value announces. | - |
format | ((value: number) => string)Formats the number, with the app's formatter. | - |
max | numberThe top of the scale. Defaults to 100. | 100 |
tone | "neutral" | "high" | "low" | "critical" | "medium" | "unknown"Severity the score maps to, which colors the number and the bar. Leave it `neutral` below the levels that need attention. | neutral |
value* | number | null | undefinedThe score. A missing value shows Empty Value. | - |
DataTableTags
DataTableTags shows a list of tags in one line of a DataTable cell: the first few as chips, then a "+N" counter that keyboard users can focus, with the rest in a tooltip and in its accessible name. A fixed number of chips keeps every row the same, whatever the column width.
| Prop | Type | Default |
|---|---|---|
emptyLabel | stringAlready-translated label Empty Value announces. | - |
items* | readonly string[]The tags, already translated. None shows Empty Value. | - |
max | numberChips shown before the "+N" counter. Defaults to 2. | 2 |
moreLabel | ((count: number, hidden: readonly string[]) => string)Already-translated name of the counter for screen readers, e.g. "3 more: c2, rat, botnet". | (count, hidden) => `${count} more: ${hidden.join(", ")}` |
DataTableText
DataTableText is the text of a DataTable cell: a value on one line, truncated with its full text on hover, optionally with an icon beside it and a smaller secondary line under it. The value can be the row's link or button. Use it for identity, text, date and number cells, so every table truncates, aligns and marks empty values the same way.
| Prop | Type | Default |
|---|---|---|
emptyLabel | stringAlready-translated label Empty Value announces. | - |
href | stringMakes the value a link, the row's keyboard path. DataTable's `getRowHref` follows it on a click anywhere on the row. | - |
icon | ReactNodeA 14px icon beside the value, on the first line: the kind of value, never decoration. | - |
mono | booleanMonospace, for identifiers: hashes, IPs, hosts, keys, IDs. | false |
numeric | booleanTabular figures, for dates, times and numbers. | false |
onClick | ((event: MouseEvent<HTMLButtonElement, MouseEvent>) => void)Makes the value a button, for rows that open in place (a sheet). | - |
render | ReactElement<unknown, string | JSXElementConstructor<any>>Renders the value as your own link element, such as a router Link. | - |
secondary | ReactNodeA second, smaller line that qualifies the value: the type under an indicator, the time under a date, the email under a name. Needs a table with `lines={2}`. | - |
strong | booleanSemibold, for the value that names the row. | false |
title | stringThe full value on hover. Defaults to the value, when it is text. | - |
DataTableColumnsMenu
DataTableColumnsMenu lets the user show and hide a DataTable's columns, named from `meta.label` or the header. It never offers to hide the fluid column, columns with `enableHiding: false`, or the last visible column. Put it at the end of the table's toolbar.
| Prop | Type | Default |
|---|---|---|
label | stringAlready-translated text of the button and the menu title. Defaults to "Columns". | Columns |
table* | DataTableInstance<TData>The table from useDataTable. | - |
actionsColumn
actionsColumn returns the column for a row's actions: pinned to the end, aligned to the end, with a header only screen readers hear. Give the menu button an aria-label that names the row ("Actions for fs-prod-02").
| Prop | Type | Default |
|---|---|---|
cell* | (row: TData) => ReactNodeThe row's controls, usually a menu with an aria-label naming the row. | - |
label | stringAlready-translated name of the column for screen readers. Defaults to "Actions". | Actions |
size | numberWidth in px. Defaults to 56: one small icon button and the cell padding. | 56 |
isAnyOf
isAnyOf filters a column to the rows whose value is one of the selected values: the filter behind a facet with checkboxes (Status: Open, Acknowledged). Set it as the column's `filterFn` and the column filter to an array of values; an empty array clears the filter. Exact matches only, unlike TanStack's `arrIncludesSome`, which matches substrings of a text value.
No props of its own. Accepts the props of the element it renders.
selectColumn
selectColumn returns the checkbox column for row selection: pinned to the start, 40px, a header checkbox that selects the rows on the page (or every loaded row when the table does not page), a label per row, and Shift+click to select a range.
| Prop | Type | Default |
|---|---|---|
getRowLabel | ((row: TData) => string)The row's name for its checkbox label ("Select fs-prod-02"). Without it the label uses the row id. | - |
labels | { selectAll?: string; selectPage?: string; selectRow?: ((rowLabel: string) => string) | undefined; column?: string | undefined; } | undefinedAlready-translated labels. | - |
sortByRank
sortByRank sorts a column by a fixed order instead of alphabetically: severity (`["critical", "high", "medium", "low"]`), status, priority. Ascending follows the order given; values not in it go last.
No props of its own. Accepts the props of the element it renders.
DataTable
DataTable shows records in rows and columns: alerts, events, assets, findings, users. Build the table with useDataTable and render it here. Headers sort, rows select and open, long lists page, grow or render only the rows in view, and column widths never move: they come from the column definitions, never from the content.
| Prop | Type | Default |
|---|---|---|
activeRowId | stringThe id of the row open in a detail view: highlighted, and scrolled into view when it changes. | - |
autoLoadLimit | numberRows loaded on their own before loading needs a click. Defaults to 1,000. | 1000 |
emptyState | ReactNodeReplaces the default empty state; pass a ListEmptyState with the page's wording. | - |
error | booleanThe last request failed. Rows already loaded stay. | false |
fetching | booleanRows are refreshing in the background: a thin bar, the rows stay usable. | false |
focusableRows | booleanRows take keyboard focus and open with Enter. Only for rows without a link or button in their identity cell, which is the keyboard path otherwise. | false |
getRowAccent | ((row: TData) => SeverityLevel)A severity accent on the row's leading edge. It repeats a value the row shows as text (a severity or score column), never replaces it. | - |
getRowHref | ((row: TData) => string)The row's URL. Render the real link in the identity cell (`DataTableText` with `href` or `render`): a click anywhere on the row follows it, Cmd/Ctrl+click and a middle click open it in a new tab. Only http, https and relative URLs are followed. | - |
hasMore | booleanMore rows exist after the loaded ones. | false |
headerDividers | booleanVertical rules between the header cells. Off by default; turn it on where several narrow columns sit side by side, such as a count per severity, so each value reads under its own header. The body never has vertical rules. | false |
height | number | "auto" | "fill"`fill`: the table fills a parent with a bounded height (a flex column) and scrolls inside, under a sticky header. A number: it scrolls inside from that height, in px. `auto`: it grows with its rows and the page scrolls; for short tables only. | auto |
label* | stringAlready-translated name of the table, for screen readers ("Indicators of compromise"). | - |
labels | Partial<DataTableLabels>Replaces the default English texts. | - |
lines | 1 | 2Text lines per row: 1 (36px rows) or 2, for cells with a secondary line (52px rows). | 1 |
loading | booleanThe first rows are loading: skeleton rows with the final column widths. | false |
loadingMore | booleanThe next rows are loading. | false |
loadMoreError | booleanLoading more rows failed; the loaded rows stay. | false |
onLoadMore | (() => void)Loads the next rows of a growing list (server mode, cursor APIs). The table calls it when the end of the list comes into view, also when the first rows do not fill it, and offers a "Load more" button. Needs a table that scrolls inside (`height` is `fill` or a number). | - |
onRetry | (() => void)Retries the failed request, from the error states. | - |
onRowClick | ((row: TData) => void)Opens a row in place, such as in a sheet. Clicks on links, buttons, checkboxes, menus and text selections inside the row do not count. | - |
pageSizeOptions | number[]The page sizes offered in the footer of a paged table. | [25, 50, 100] |
resultKey | stringIdentifies the query behind `total`, such as the URL params of the search and filters. A new key announces the total even when the number did not change; sorting and paging should not change it. | - |
table* | DataTableInstance<TData>The table from useDataTable. | - |
total | numberThe number of records the search and filters match. The table announces it once the rows settle after it changes, so a filter change is heard; show the number on screen yourself, such as in the toolbar. | - |
virtualize | booleanRender only the rows in view. On by itself above 500 rows when the table scrolls inside. Paged and short tables keep every row in the page, so find in page and copy keep working. | - |
useDataTable
useDataTable builds the table behind a DataTable: rows, columns and the state of sorting, filters, search, selection, column visibility and paging, each controlled or uncontrolled. Pass the result to DataTable and to the toolbar pieces around it (search, facets, the Columns menu, a bulk action bar), so they all read one state.
| Prop | Type | Default |
|---|---|---|
columnFilters | ColumnFiltersStateColumn filters (`[{ id: "status", value: ["open"] }]`), controlled. | - |
columns* | DataTableColumnDef<TData, any>[]Column definitions; build them with createColumnHelper and keep them stable. | - |
columnVisibility | ColumnVisibilityStateHidden columns by id (`{ tags: false }`), controlled. | - |
data* | TData[]The rows. In `server` mode, the rows of the current page or loaded so far. | - |
defaultColumnFilters | ColumnFiltersState | [] |
defaultColumnVisibility | ColumnVisibilityState | {} |
defaultGlobalFilter | string | - |
defaultPageSize | numberPages the rows from this page size, uncontrolled. | - |
defaultSorting | SortingStateThe initial sort, uncontrolled. | [] |
enableRowSelection | boolean | ((row: TData) => boolean)Which rows can be selected. All of them by default. | true |
getRowId* | (row: TData, index: number) => stringA stable, unique id per row: selection, focus and virtualization key on it. | - |
globalFilter | stringThe search text that filters every column, controlled. | - |
mode | "client" | "server"Who sorts, filters and pages the rows. `client` (the default): the table does it from `data`, with facet counts. `server`: `data` arrives sorted, filtered and paged, and the table only reports what the user asked for. In `server` mode a column sorts only when its definition sets `enableSorting: true`, so a header never offers a sort the server will not apply. | client |
onColumnFiltersChange | ((filters: ColumnFiltersState) => void) | - |
onColumnVisibilityChange | ((visibility: ColumnVisibilityState) => void) | - |
onGlobalFilterChange | ((filter: string) => void) | - |
onPaginationChange | ((pagination: PaginationState) => void) | - |
onRowSelectionChange | ((selection: RowSelectionState) => void) | - |
onSortingChange | ((sorting: SortingState) => void) | - |
pagination | PaginationStateThe page, controlled. Pass it, or `defaultPageSize`, to page the rows; leave both out to show every row in one list. With controlled pagination, reset `pageIndex` to 0 yourself in the same update that changes the sort or the filters (the table does it for uncontrolled pagination): two separate callbacks would race in a URL store. | - |
rowCount | numberThe total number of rows in `server` mode, for the page range and the page count. | - |
rowSelection | RowSelectionStateSelected rows by id, controlled. Add a selection column with selectColumn(). | - |
selectionScope | "page" | "persist"What a selection survives. `page` clears it on a new page, sort or filter: bulk actions then only reach rows the user can see. `persist` keeps it. Defaults to `page` in server mode and `persist` in client mode. | - |
sorting | SortingStateThe sort, controlled. | - |
createColumnHelper
createColumnHelper returns TanStack Table's column helper, typed for DataTable: `accessor` infers each value's type from the row, `display` builds non-data columns. Define the columns once, outside the component or in a useMemo, so the table keeps its state and its rows stay memoized.
No props of its own. Accepts the props of the element it renders.