Skip to content
UI

Table

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

@zenoxai/ui/components/table Markdown
Open alerts, most recent first.
IDAlertAssetSeverityEvents
ALR-4821Impossible travel sign-inm.rocha@acme.comCritical3
ALR-4820Outbound traffic to a known C2 domainsrv-web-02High1,284
ALR-4817RDP exposed to the internetvm-jump-01High57
ALR-4815TLS certificate expires in 5 daysportal.acme.comMedium1
ALR-4809Unsigned PowerShell script ranwks-0193Low12
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

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 and record state with StatusBadge, 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, never a blank cell.

For a short list of records with one or two attributes each, an Item list reads better than a table.

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

SourceAlertsEvents
Endpoint (EDR)41298,234
Identity provider16720,311
Email gateway894,872
Total668123,417
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 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.

AssetOperating systemOwner
srv-web-02Ubuntu 22.04Infrastructure
vm-jump-01Windows Server 2019IT operations
wks-0193Windows 11Finance
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.

EventTimeSourceBytes
EVT-9041210/23/2026 14:59:07firewall-edge-011,200
EVT-9041110/23/2026 14:57:07vpn-gw-021,571
EVT-9041010/23/2026 14:55:07dc-011,942
EVT-9040910/23/2026 14:53:07proxy-032,313
EVT-9040810/23/2026 14:51:07firewall-edge-012,684
EVT-9040710/23/2026 14:49:07vpn-gw-023,055
EVT-9040610/23/2026 14:47:07dc-013,426
EVT-9040510/23/2026 14:45:07proxy-033,797
EVT-9040410/23/2026 14:43:07firewall-edge-014,168
EVT-9040310/23/2026 14:41:07vpn-gw-024,539
EVT-9040210/23/2026 14:39:07dc-014,910
EVT-9040110/23/2026 14:37:07proxy-035,281
EVT-9040010/23/2026 14:35:07firewall-edge-015,652
EVT-9039910/23/2026 14:33:07vpn-gw-026,023
EVT-9039810/23/2026 14:31:07dc-016,394
EVT-9039710/23/2026 14:29:07proxy-036,765
EVT-9039610/23/2026 14:27:07firewall-edge-017,136
EVT-9039510/23/2026 14:25:07vpn-gw-027,507
EVT-9039410/23/2026 14:23:07dc-017,878
EVT-9039310/23/2026 14:21:07proxy-038,249
EVT-9039210/23/2026 14:19:07firewall-edge-018,620
EVT-9039110/23/2026 14:17:07vpn-gw-028,991
EVT-9039010/23/2026 14:15:07dc-019,362
EVT-9038910/23/2026 14:13:07proxy-039,733
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. When the request fails, use a ListErrorState the same way. Add whitespace-normal to the cell so the description can wrap.

AlertAssetSeverityEvents
No alerts match these filters
Try a wider time range or fewer filters.
<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

Generated from the TypeScript source. className, style and children are accepted everywhere and omitted below.

Table

Table is a styled HTML table for data in rows and columns, in a container that scrolls sideways when the table is too wide. It has no sorting, selection or pagination of its own; build those around it. For a list of entries with media and actions but no columns, use Item.

PropTypeDefault
containerClassNamestring

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.

-
containerLabelstring

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

PropTypeDefault
stickyboolean

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.

false

TableBody

No props of its own. Accepts the props of the element it renders.

TableFooter

No props of its own. Accepts the props of the element it renders.

TableHead

No props of its own. Accepts the props of the element it renders.

TableRow

No props of its own. Accepts the props of the element it renders.

TableCell

No props of its own. Accepts the props of the element it renders.

TableCaption

TableCaption is the table's title, shown below it and read as its name.

No props of its own. Accepts the props of the element it renders.