# 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](/patterns/data-table); format the values with [Data display](/guidelines/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](#a-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](/components/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](#two-views-of-the-same-records)).

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

```tsx
import * as React from "react"
import { FileCodeIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { DataTable, DataTableScore, DataTableTags, DataTableText, createColumnHelper, useDataTable } from "@zenoxai/ui/patterns/data-table"

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](/guidelines/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](/patterns/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](/patterns/copy-button). 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](/patterns/data-table#api-datatabletags)).
- **Emphasis** belongs to the identity column. The one exception is a score in a severity tone
  ([Data Table Score](/patterns/data-table#api-datatablescore)), 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](/patterns/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](/guidelines/filtering).
- 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](/guidelines/empty-states) 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](/components/item) list                 |
| Are settings that turn on and off                   | Fields and switches in a form                    |
| Are one record                                      | A [Description List](/patterns/description-list) |
| Are measured over time                              | A [Chart](/components/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.
