# Table

The styled parts of an HTML table, at the platform's dense row height.

```tsx
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table"
import { SeverityBadge } from "@zenoxai/ui/patterns/severity-badge"

function TableDemo() {
  const alerts = [
    {
      id: "ALR-4821",
      title: "Impossible travel sign-in",
      asset: "m.rocha@acme.com",
      severity: "critical",
      events: 3,
    },
    {
      id: "ALR-4820",
      title: "Outbound traffic to a known C2 domain",
      asset: "srv-web-02",
      severity: "high",
      events: 1284,
    },
    {
      id: "ALR-4817",
      title: "RDP exposed to the internet",
      asset: "vm-jump-01",
      severity: "high",
      events: 57,
    },
    {
      id: "ALR-4815",
      title: "TLS certificate expires in 5 days",
      asset: "portal.acme.com",
      severity: "medium",
      events: 1,
    },
    {
      id: "ALR-4809",
      title: "Unsigned PowerShell script ran",
      asset: "wks-0193",
      severity: "low",
      events: 12,
    },
  ] as const

  return (
    <Table containerLabel="Open alerts">
      <TableCaption>Open alerts, most recent first.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>ID</TableHead>
          <TableHead>Alert</TableHead>
          <TableHead>Asset</TableHead>
          <TableHead>Severity</TableHead>
          <TableHead className="text-right">Events</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {alerts.map((alert) => (
          <TableRow key={alert.id}>
            <TableCell className="font-mono text-text-muted">
              {alert.id}
            </TableCell>
            <TableCell className="font-medium">{alert.title}</TableCell>
            <TableCell className="font-mono">{alert.asset}</TableCell>
            <TableCell>
              <SeverityBadge level={alert.severity} />
            </TableCell>
            <TableCell className="text-right tabular-nums">
              {alert.events.toLocaleString("en-US")}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

## Usage

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@zenoxai/ui/components/table"

export function AlertsTable() {
  return (
    <Table aria-label="Open alerts">
      <TableHeader>
        <TableRow>
          <TableHead>Alert</TableHead>
          <TableHead className="text-right">Events</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          <TableCell>RDP exposed to the internet</TableCell>
          <TableCell className="text-right tabular-nums">57</TableCell>
        </TableRow>
      </TableBody>
    </Table>
  )
}
```

These are the `<table>` elements with the platform's one table density: a 40px header on a
tinted band (`bg-surface-muted`, labels in `text-text-secondary`) that sets it apart from the
rows, compact cells on the surface, a hover tint on rows and rules on the subtle border color. Sorting, pagination and
virtualization are not part of the component; build them in the app on top of these parts.

Conventions for every table:

- Right-align numbers, header included, and add `tabular-nums` so the digits line up. Format
  them in the app for the user's locale.
- Show identifiers, hostnames and hashes in `font-mono`.
- Show severity with [SeverityBadge](/patterns/severity-badge) and record state with
  [StatusBadge](/patterns/status-badge), never with colored text alone.
- Cells do not wrap. `Table` scrolls horizontally when the columns do not fit. Truncate a long
  value with `max-w-*` on the cell and `truncate` plus `title` on the text.
- Show a missing value with [Empty Value](/patterns/empty-value), never a blank cell.

For a short list of records with one or two attributes each, an [Item](/components/item) list
reads better than a table.

## With a footer

`TableFooter` holds totals. It has a top rule and a muted background.

```tsx
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table"

function TableWithFooter() {
  const sources = [
    { name: "Endpoint (EDR)", alerts: 412, events: 98234 },
    { name: "Identity provider", alerts: 167, events: 20311 },
    { name: "Email gateway", alerts: 89, events: 4872 },
  ]
  const total = sources.reduce(
    (sum, source) => ({
      alerts: sum.alerts + source.alerts,
      events: sum.events + source.events,
    }),
    { alerts: 0, events: 0 }
  )

  return (
    <Table className="max-w-md">
      <TableHeader>
        <TableRow>
          <TableHead>Source</TableHead>
          <TableHead className="text-right">Alerts</TableHead>
          <TableHead className="text-right">Events</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {sources.map((source) => (
          <TableRow key={source.name}>
            <TableCell>{source.name}</TableCell>
            <TableCell className="text-right tabular-nums">
              {source.alerts.toLocaleString("en-US")}
            </TableCell>
            <TableCell className="text-right tabular-nums">
              {source.events.toLocaleString("en-US")}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell>Total</TableCell>
          <TableCell className="text-right tabular-nums">
            {total.alerts.toLocaleString("en-US")}
          </TableCell>
          <TableCell className="text-right tabular-nums">
            {total.events.toLocaleString("en-US")}
          </TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}
```

## Selectable rows

Put a [Checkbox](/components/checkbox) in the first column and set `data-state="selected"` on the
selected rows. A selected row takes the selection colors (`bg-primary-soft`), so it never looks
like a hovered one. The cell drops its right padding when it holds a checkbox. The header
checkbox selects all rows and shows the indeterminate state for a partial selection.

```tsx
import * as React from "react"
import { Checkbox } from "@zenoxai/ui/components/checkbox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table"

function TableSelectable() {
  const assets = [
    { id: "srv-web-02", os: "Ubuntu 22.04", owner: "Infrastructure" },
    { id: "vm-jump-01", os: "Windows Server 2019", owner: "IT operations" },
    { id: "wks-0193", os: "Windows 11", owner: "Finance" },
  ]
  const [selected, setSelected] = React.useState<string[]>(["vm-jump-01"])
  const allSelected = selected.length === assets.length

  return (
    <Table className="max-w-lg" containerLabel="Assets">
      <TableHeader>
        <TableRow>
          <TableHead className="w-8">
            <Checkbox
              aria-label="Select all assets"
              checked={allSelected}
              indeterminate={selected.length > 0 && !allSelected}
              onCheckedChange={(checked) =>
                setSelected(checked ? assets.map((asset) => asset.id) : [])
              }
            />
          </TableHead>
          <TableHead>Asset</TableHead>
          <TableHead>Operating system</TableHead>
          <TableHead>Owner</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {assets.map((asset) => {
          const isSelected = selected.includes(asset.id)
          return (
            <TableRow
              key={asset.id}
              data-state={isSelected ? "selected" : undefined}
            >
              <TableCell>
                <Checkbox
                  aria-label={`Select ${asset.id}`}
                  checked={isSelected}
                  onCheckedChange={(checked) =>
                    setSelected((current) =>
                      checked
                        ? [...current, asset.id]
                        : current.filter((id) => id !== asset.id)
                    )
                  }
                />
              </TableCell>
              <TableCell className="font-mono">{asset.id}</TableCell>
              <TableCell>{asset.os}</TableCell>
              <TableCell>{asset.owner}</TableCell>
            </TableRow>
          )
        })}
      </TableBody>
    </Table>
  )
}
```

## Long tables

A long table scrolls inside its own container and keeps its header in view: give the container
a height with `containerClassName` and set `sticky` on `TableHeader`. The container scrolls on
both axes, so a header cannot stick to the page's own scroll. Name the container with
`containerLabel`, so keyboard users can scroll it.

```tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table"

function TableStickyHeader() {
  const sources = ["firewall-edge-01", "vpn-gw-02", "dc-01", "proxy-03"]
  const events = Array.from({ length: 24 }, (_, index) => ({
    id: `EVT-${(90412 - index).toString()}`,
    time: `10/23/2026 14:${(59 - index * 2).toString().padStart(2, "0")}:07`,
    source: sources[index % sources.length],
    bytes: 1200 + index * 371,
  }))

  return (
    <Table
      containerLabel="Network events"
      containerClassName="max-h-72 rounded-md border border-border-subtle"
    >
      <TableHeader sticky>
        <TableRow>
          <TableHead>Event</TableHead>
          <TableHead>Time</TableHead>
          <TableHead>Source</TableHead>
          <TableHead className="text-right">Bytes</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {events.map((event) => (
          <TableRow key={event.id}>
            <TableCell className="font-mono text-text-muted">
              {event.id}
            </TableCell>
            <TableCell className="tabular-nums">{event.time}</TableCell>
            <TableCell className="font-mono">{event.source}</TableCell>
            <TableCell className="text-right tabular-nums">
              {event.bytes.toLocaleString("en-US")}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

## Empty and error states

When the request returns no rows, keep the header and render one row whose cell spans every
column and holds a [ListEmptyState](/patterns/list-empty-state). When the request fails, use a
[ListErrorState](/patterns/list-error-state) the same way. Add `whitespace-normal` to the cell so
the description can wrap.

```tsx
import { FilterXIcon, SearchXIcon } from "lucide-react"
import { Button } from "@zenoxai/ui/components/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table"
import { ListEmptyState } from "@zenoxai/ui/patterns/list-empty-state"

function TableEmpty() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Alert</TableHead>
          <TableHead>Asset</TableHead>
          <TableHead>Severity</TableHead>
          <TableHead className="text-right">Events</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow className="hover:bg-transparent">
          <TableCell colSpan={4} className="whitespace-normal">
            <ListEmptyState
              icon={SearchXIcon}
              title="No alerts match these filters"
              description="Try a wider time range or fewer filters."
              action={
                <Button variant="outline" size="sm">
                  <FilterXIcon data-icon="inline-start" />
                  Clear filters
                </Button>
              }
            />
          </TableCell>
        </TableRow>
      </TableBody>
    </Table>
  )
}
```

## Accessibility

- When the table can be wider than its container, pass `containerLabel` (already translated).
  The scrolling container becomes a focusable region with that name, so keyboard users can
  scroll it sideways with the arrow keys.

- Give every table a name: a `TableCaption`, or `aria-label` or `aria-labelledby` on `Table`
  pointing at the card title above it.
- `TableHead` renders `<th>`. For a row header, such as the first cell of each row in a
  comparison, render `<TableHead scope="row">` in the body.
- Each row checkbox needs an `aria-label` that names the row: "Select vm-jump-01", not "Select".

## API reference

`import { ... } from "@zenoxai/ui/components/table"`

### Table

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| containerClassName | `string` |  | Classes for the scrolling container. Give it a max height (`max-h-96`) for a table that scrolls inside the page, together with a `sticky` TableHeader. The container scrolls on both axes, so a header cannot stick to the page's own scroll. |
| containerLabel | `string` |  | Already-translated name of the scrolling container. Set it when the table can be wider than its container: the container then becomes a focusable, named region, so keyboard users can scroll it sideways. |

### TableHeader

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| sticky | `boolean` | `false` | Keeps the header at the top of the table's scrolling container (see `containerClassName` on Table), with its bottom rule drawn as a shadow: a collapsed border would scroll away with the rows. |

### TableBody

No props of its own.

### TableFooter

No props of its own.

### TableHead

No props of its own.

### TableRow

No props of its own.

### TableCell

No props of its own.

### TableCaption

No props of its own.

