Skip to content
UI

Tables

How to decide what a table shows and in what order, so a list of records answers a question instead of listing fields.

A table is a tool for a job: triage these alerts, find that sign-in, account for every asset. Its columns follow from that job, not from the fields the API returns. This guideline is a method in five steps, then the rules for each part of the table. Build the result with Data Table; format the values with Data display.

The method

  1. Name the job. What does the user do with this list? Triage, investigate, inventory, audit or configure.
  2. Choose the row. What is one row: an event, an entity or a case? Pick the thing the user acts on.
  3. Find the deciding attribute. Which value changes what the user does next? It goes right after the row’s name and drives the default sort.
  4. Choose and order the columns. Keep what people scan, compare, sort by or decide from; send the rest to the detail view, a filter or the Columns menu.
  5. Decide how the list moves. The default sort, the filters, and whether the list pages or grows.

The worked example at the end runs the five steps on an alerts API.

1. Name the job

zx/table-job The table’s job sets its columns and its default sort

Job Examples What the table needs
Triage: decide what to act on next Alerts, findings, risks The deciding attribute (severity, priority) right after the name, the state, sorted by it then newest
Investigate: follow a thread Events, indicators, sign-ins Time first, to the second; identifiers in monospace with copy; search; dense rows
Inventory: account for what exists Assets, users, API keys The name, then owner and status, then last seen; facets; pages with a total; the Columns menu
Audit: show what happened Audit log, exports When, who, what (the change in one line), where; the full change in the detail view; pages
Configure: manage a few items Rules, webhooks, roles Name, what it does, whether it is on, actions at the end; often an Item list

Write the job down before the columns. When a page serves two jobs, such as triage and investigation, start from the more frequent one and offer the other as a view (see two views).

2. Choose the row

zx/table-grain One row is one thing of one kind

The most common mistake in a security table is the wrong grain. The same data reads as different tables:

Rows are The table answers Example
Events: something happened What happened, when, in order? Sign-ins, process starts, DNS queries
Entities: something exists What do we have, and in what state? Assets, users, domains, with a count of open findings
Cases: something to act on What needs a decision, and how urgent? Alerts, findings (one CVE on one asset), incidents

Pick the grain of the thing the user acts on. Findings to fix are one row per finding, not one row per asset with a list of CVEs; assets to inventory are one row per asset, with the count of its open findings. Never mix grains in one table: a row of totals among records, or events among cases.

3. Find the deciding attribute

The deciding attribute is the value that changes what the user does next with a row: open it now, assign it, ignore it. Every job has one or two:

Job Deciding attribute
Triage Severity or priority, then status
Investigate Time, then the actor or the target
Inventory Status or exposure, then owner
Audit The action and who did it
Configure Whether the item is on

It goes right after the row’s name (time goes first in chronological lists), it is the default sort, and it is usually a facet.

4. Choose and order the columns

zx/table-identity The identity column names the row

  • The identity column holds the value that names the record: a title, hostname, indicator, email. It is the only semibold text in the row (strong), takes the remaining width (meta.fluid), and is the row’s link or button: its keyboard path. Pin it to the start (meta.sticky: "start") when the table can scroll sideways.
  • Show a record ID as a column only when people cite it in tickets or reports (“ALR-4821”). Otherwise it belongs in the detail view.

Which fields become columns

For each field the API returns, ask:

  1. Do people decide from it in the list? Severity, status, owner, last seen: a column.
  2. Do they need it only once a record is open? Description, raw payload, full history: the detail view.
  3. Is it the same in most rows? The tenant on a page filtered to one tenant, the source on a single-source feed: page context or a filter, not a column.
  4. Does it only qualify another value? The indicator’s type, the time of a date, a user’s email: the second line of that value’s cell.
  5. Do people only filter by it? Tags, MITRE tactics, the rule that fired: a facet, and a column behind the Columns menu.

Budget the columns by the table’s container, not the screen: the identity column’s minimum plus the sum of the other widths must fit the container the table usually gets, with the filter rail or a detail sheet open. That is four to seven columns besides selection and actions in most pages. Put the rest behind the Columns menu (defaultColumnVisibility) or in the detail view. A wide value (a description, JSON, a long list) is never a column.

Order

Left to right, in the order people read and decide. The default order:

Position Holds Examples
1 Selection, when bulk actions exist Checkbox
2 Identity Alert title, hostname, indicator
3 The deciding attribute Severity, priority, status
4 Context: where and who Asset, owner, source, tenant
5 Classification Type, category, tags
6 Time Last seen, updated
7 Actions The row’s menu, always last

Chronological lists (events, sign-ins, the audit log) put time first, right after selection: they are read in time order, and the time is what people compare across rows. An audit row then reads as a sentence: when, who, did what, to what.

Keep the same order for the same records everywhere: the alerts table on the dashboard and on the alerts page read the same way.

Merge or split

zx/table-two-line A second line holds a qualifier, never a second fact

Use two-line rows (lines={2}) when a value needs a qualifier to be read: the type under an indicator, the time under a date. The second line saves a column and keeps related values together.

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>
  )
}
Merge into one two-line cell Keep in separate columns
An indicator and its type (Domain, IP:Port) Two values people sort or filter by separately
A date and its time Values compared across rows: counts, scores, sizes
A person’s name and email A severity and a status
A hostname and its IP, when the IP only identifies it Values that belong to different records
  • The second line is smaller and in the secondary text color. It is never a sentence and never a value of another kind.
  • The column’s accessor returns the full value, so sorting and search cover both lines (a date and its time sort as one timestamp); the title shows the first line.
  • A table is one-line or two-line throughout. When only one column would need a second line, split it into two columns instead.
  • An icon for the kind of value sits beside the value on the first line; the second line starts at the column’s edge, under the icon.

Two views of the same records

When the same records serve triage and dense review, offer two views of one table: a two-line list (qualifiers under their values, fewer columns) and a one-line grid (each qualifier in its own column, more rows on screen). Each view is its own set of columns, because a cell with a second line does not fit a one-line row. Switch columns and lines together, and remember the user’s choice in the app.

Headers

  • A header names the column in one or two words, in sentence case: “Last seen”, not “Last Seen Date”. The unit or time zone goes in the header’s help (meta.description), not in every cell.
  • Use the help icon for what a reader may not know: how a score is computed, which time zone, what counts as open. Never for anything needed to use the table.
  • Rules between header cells (headerDividers) help where several narrow columns sit side by side, such as a count per severity: the eye needs the boundaries to match a number to its header. Tables with few, wide columns read better without them. The body never has vertical rules: alignment and whitespace separate the columns.

Widths

zx/table-widths Widths come from the columns, never from the content

Give every column a width for what it must show, and let the identity column take the rest. Content never sets a width: a width that follows the content moves when the data, the page or the scroll position changes. A column is as wide as the larger of its widest expected value and its header (the label, the sort arrow and any help icon), plus 24px of padding.

Column holds Width
Selection checkbox 40
Actions (one icon button) 56
Severity badge 110
Status badge with its icon 130 to 150
Score with its bar 120 to 150
Number 90 to 150, by the header
Date and time on one line 170 to 190
Date and time with the time zone 200
Date over time (two lines) 120 to 130
IPv4, IPv4 and port 140, 180
IPv6 240
Hostname, FQDN, email 220, or the identity column
Short label: type, source, category 120 to 150
Tags: two chips and “+N” 200 to 220
Identity Fluid, at least 240 to 320

When the columns do not fit, the table scrolls sideways: selectColumn() and actionsColumn() pin themselves, pin the identity with meta.sticky: "start", and hide low-priority columns on narrow screens with meta.hideBelow.

Values

Format every value as Data display says: numbers end-aligned with tabular figures, identifiers in monospace, dates absolute and to the second where events are compared, a missing value as Empty Value, severity and status as badges with their labels.

  • Truncate at the end, with the full value in the title and in the detail view. Hashes, long hostnames and paths belong in the identity column, where they get the most width. Identifiers people paste elsewhere offer copy through Value Actions. Do not widen a column to avoid truncation if it pushes a deciding column off screen.
  • Lists in a cell show a fixed number of chips and a “+N” that names the rest (Data Table Tags).
  • Emphasis belongs to the identity column. The one exception is a score in a severity tone (Data Table Score), which marks the values that need attention.
  • Color means something only through a badge with its label or a score’s tone. The row’s severity accent repeats a value the row already shows as text, on the same scale as that value.

Time

  • Show the time that answers the job: last seen for indicators and assets (is it still active?), detected or created for alerts, the event time for events. Show two times only when both decide something, such as first and last seen of an indicator.
  • Time columns sort newest first on the first click (sortDescFirst).

Counts

  • A count is a number, end-aligned. Zero is “0”, never Empty Value: it is a value.
  • A count that people follow (open findings of an asset, events of an alert) links to the list it counts, already filtered.

Sorting

zx/table-sort A sortable header sorts the whole list

  • The default sort answers the job: triage by severity or priority, then newest (defaultSorting with both keys); events and the audit log newest first; inventories by name.
  • Make sortable only the columns people compare: time, severity, priority, score, counts, names. In client mode every column sorts unless it sets enableSorting: false; turn it off on tags, descriptions, types and anything people do not compare.
  • When the server pages, only the columns the server can sort are sortable (mode: "server" with enableSorting: true). A header that reorders only the loaded rows misleads.
  • Severity, status and priority sort by rank (sortByRank), never alphabetically.
  • Empty values sort last in both directions; DataTable does it in client mode, and a server sort uses NULLS LAST.

Filters and facets

  • Columns with a few distinct values (severity, status, owner, type, source) get a facet: a checkbox list with the count of each value among the other filters’ results. Free text and identifiers get the search box. Numbers and dates get a range.
  • Filter on what the user decides by: the deciding attribute and the context columns.
  • One to three facets go in the toolbar; four or more go in a Filter panel. Every active filter stays visible, in the panel or as a chip under the toolbar while the panel is closed. The rest is in the filtering guideline.
  • In client mode the facet counts come from the table (column.getFacetedUniqueValues(), with isAnyOf() as the filter). In server mode they come from the API: counts from the loaded page describe only that page.

Paging and long lists

zx/table-paging Page lists people cite; grow feeds people scan

List Use
Known total, positions people cite: alerts, assets, audit log Pages with the total: “1 to 50 of 1,204”
Cursor API, read in order: events, indicator feeds, leaks A growing list: loads as the end comes into view, “Load more”, the loaded count
A few hundred rows loaded at once: users, keys, rules One list in a scrolling table (height="fill")
  • A growing list always scrolls inside the table (height="fill" or a number), never with the page, and stops loading on its own after 1,000 rows.
  • Lists that are exported or audited are paged, never growing. Keep the page in the URL, so a position survives a reload and can be shared.
  • A list that scrolls inside the table and has more than 500 rows renders only the rows in view (virtualization). Browser find does not reach the others, so give the page a search.

States

Every table has four states besides its rows: loading (skeleton rows with the final widths), empty, failed and refreshing. Word the empty state for its cause, as the empty states guideline says: nothing yet, nothing matches the search, or filtered out. A failed refresh keeps the rows already loaded.

A worked example

An alerts API returns 20 fields per alert: id, title, description, severity, status, assignee, tenant, source product, rule id, rule name, asset hostname, asset IP, user, created at, updated at, first event at, last event at, tags, MITRE tactics and event count. The page is the alerts queue of one tenant.

  1. Job: triage. The analyst decides which alert to open next.
  2. Row: one alert, the case the analyst acts on. Not one row per event.
  3. Deciding attribute: severity, then status. They follow the title, sort the list (severity, then the newest last event) and become facets.
  4. Columns:
    • Identity: the title, semibold, a link to the alert, pinned to the start.
    • Severity and status: badges.
    • Asset: the hostname, with the IP as its second line (lines={2}), because the IP only identifies the host.
    • Assignee: who is on it, a facet too.
    • Last event: the date with the time under it; its help says the time zone.
    • Events: the count, end-aligned, linking to the alert’s events.
    • Behind the Columns menu: source product, rule name, created at.
    • Facets only: tags and MITRE tactics.
    • Detail view only: description, rule id, user, first event and updated at.
    • Nowhere: the tenant, which the page already names, and the id, which nobody cites from this list.
  5. Movement: pages with the total, the page in the URL; facets for severity, status, assignee and source; search on the title and the hostname.

Seven columns, all of which change what the analyst does next.

When a list is not a table

The records Use
Have one or two attributes, or images and long text An Item list
Are settings that turn on and off Fields and switches in a form
Are one record A Description List
Are measured over time A Chart

Review checklist

Before shipping a table, check that:

  1. The table’s job is written down, and the default sort answers it (zx/table-job).
  2. One row is one thing of one kind, the thing the user acts on (zx/table-grain).
  3. The identity column is first (after time, in chronological lists), fluid, semibold and the row’s link (zx/table-identity).
  4. The deciding attribute follows the identity and is a facet.
  5. The columns fit the table’s usual container; the rest are behind the Columns menu or in the detail view.
  6. Second lines hold qualifiers only (zx/table-two-line).
  7. Every column except the identity has a width that fits its values and its header (zx/table-widths).
  8. Only compared columns sort, and they sort the whole list (zx/table-sort).
  9. The list pages or grows according to its use (zx/table-paging).
  10. Empty values show Empty Value, zero shows 0, numbers align to the end, color repeats text.