Skip to content
UI

Data Table

beta

Records 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
CriticalOpen
fs-prod-02.corp.acme.com
Oct 24, 2026, 2:57:26 PM
Leaked API key in a public repository
CriticalOpen
github.com/acme/infra
Oct 24, 2026, 1:22:08 PM
Outbound traffic to a known C2 domain
HighOpen
wks-0117.corp.acme.com
Oct 24, 2026, 2:52:11 PM
Credential stuffing against the VPN
HighAcknowledged
vpn.acme.com
Oct 24, 2026, 2:48:03 PM
New admin added outside the change window
MediumOpen
okta-tenant-prod
Oct 24, 2026, 2:31:40 PM
Expired TLS certificate on a public host
MediumAcknowledged
status.acme.com
Oct 24, 2026, 1:59:12 PM
Port 3389 exposed to the internet
LowResolved
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. useDataTable holds the rows and the state (sort, filters, search, selection, hidden columns, page), each part controlled or uncontrolled; DataTable renders it. The toolbar around the table (search, facets, the Columns menu, a bulk action bar) takes the same table. For four or more filters, put the table in a Filter panel and pass total: 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 size in px and mark exactly one column meta: { fluid: true }, usually the one that names the row: it takes the remaining width, never less than minWidth (240px by default). Below the sum, the table scrolls sideways; selectColumn() and actionsColumn() pin themselves, and the fluid column can pin to the start with meta.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 with secondary). 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" reports onSortingChange, onColumnFiltersChange, onGlobalFilterChange and onPaginationChange for your query; pass the total as rowCount. In server mode a column sorts only with enableSorting: true. A header toggles ascending and descending; there is no unsorted state and no multi-sort. A default sort on two columns goes in defaultSorting. Empty values (null, undefined, “”) sort last in both directions, with your own sortFn too.
  • Paging is opt-in. Pass defaultPageSize (client) or pagination (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. selectionScope says 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.

Loading rows
UserSource
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.

CredentialSourceFound
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
25 of 180 loaded
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.

Loading rows
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 by label. Header cells are th scope="col"; the sorted one carries aria-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 (-1 while the total of a growing list is unknown) and aria-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 focusableRows only 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 an aria-label that 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.

PropTypeDefault
emptyLabelstring

Already-translated label Empty Value announces.

-
format((value: number) => string)

Formats the number, with the app's formatter.

-
maxnumber

The 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 | undefined

The 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.

PropTypeDefault
emptyLabelstring

Already-translated label Empty Value announces.

-
items*readonly string[]

The tags, already translated. None shows Empty Value.

-
maxnumber

Chips 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.

PropTypeDefault
emptyLabelstring

Already-translated label Empty Value announces.

-
hrefstring

Makes the value a link, the row's keyboard path. DataTable's `getRowHref` follows it on a click anywhere on the row.

-
iconReactNode

A 14px icon beside the value, on the first line: the kind of value, never decoration.

-
monoboolean

Monospace, for identifiers: hashes, IPs, hosts, keys, IDs.

false
numericboolean

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

-
renderReactElement<unknown, string | JSXElementConstructor<any>>

Renders the value as your own link element, such as a router Link.

-
secondaryReactNode

A 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}`.

-
strongboolean

Semibold, for the value that names the row.

false
titlestring

The 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.

PropTypeDefault
labelstring

Already-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").

PropTypeDefault
cell*(row: TData) => ReactNode

The row's controls, usually a menu with an aria-label naming the row.

-
labelstring

Already-translated name of the column for screen readers. Defaults to "Actions".

Actions
sizenumber

Width 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.

PropTypeDefault
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; } | undefined

Already-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.

PropTypeDefault
activeRowIdstring

The id of the row open in a detail view: highlighted, and scrolled into view when it changes.

-
autoLoadLimitnumber

Rows loaded on their own before loading needs a click. Defaults to 1,000.

1000
emptyStateReactNode

Replaces the default empty state; pass a ListEmptyState with the page's wording.

-
errorboolean

The last request failed. Rows already loaded stay.

false
fetchingboolean

Rows are refreshing in the background: a thin bar, the rows stay usable.

false
focusableRowsboolean

Rows 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.

-
hasMoreboolean

More rows exist after the loaded ones.

false
headerDividersboolean

Vertical 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
heightnumber | "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*string

Already-translated name of the table, for screen readers ("Indicators of compromise").

-
labelsPartial<DataTableLabels>

Replaces the default English texts.

-
lines1 | 2

Text lines per row: 1 (36px rows) or 2, for cells with a secondary line (52px rows).

1
loadingboolean

The first rows are loading: skeleton rows with the final column widths.

false
loadingMoreboolean

The next rows are loading.

false
loadMoreErrorboolean

Loading 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.

-
pageSizeOptionsnumber[]

The page sizes offered in the footer of a paged table.

[25, 50, 100]
resultKeystring

Identifies 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.

-
totalnumber

The 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.

-
virtualizeboolean

Render 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.

PropTypeDefault
columnFiltersColumnFiltersState

Column filters (`[{ id: "status", value: ["open"] }]`), controlled.

-
columns*DataTableColumnDef<TData, any>[]

Column definitions; build them with createColumnHelper and keep them stable.

-
columnVisibilityColumnVisibilityState

Hidden columns by id (`{ tags: false }`), controlled.

-
data*TData[]

The rows. In `server` mode, the rows of the current page or loaded so far.

-
defaultColumnFiltersColumnFiltersState[]
defaultColumnVisibilityColumnVisibilityState{}
defaultGlobalFilterstring-
defaultPageSizenumber

Pages the rows from this page size, uncontrolled.

-
defaultSortingSortingState

The initial sort, uncontrolled.

[]
enableRowSelectionboolean | ((row: TData) => boolean)

Which rows can be selected. All of them by default.

true
getRowId*(row: TData, index: number) => string

A stable, unique id per row: selection, focus and virtualization key on it.

-
globalFilterstring

The 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)-
paginationPaginationState

The 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.

-
rowCountnumber

The total number of rows in `server` mode, for the page range and the page count.

-
rowSelectionRowSelectionState

Selected 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.

-
sortingSortingState

The 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.