Table
The styled parts of an HTML table, at the platform's dense row height.
@zenoxai/ui/components/table Markdown| ID | Alert | Asset | Severity | Events |
|---|---|---|---|---|
| ALR-4821 | Impossible travel sign-in | m.rocha@acme.com | Critical | 3 |
| ALR-4820 | Outbound traffic to a known C2 domain | srv-web-02 | High | 1,284 |
| ALR-4817 | RDP exposed to the internet | vm-jump-01 | High | 57 |
| ALR-4815 | TLS certificate expires in 5 days | portal.acme.com | Medium | 1 |
| ALR-4809 | Unsigned PowerShell script ran | wks-0193 | Low | 12 |
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-numsso 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.
Tablescrolls horizontally when the columns do not fit. Truncate a long value withmax-w-*on the cell andtruncateplustitleon 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.
With a footer
TableFooter holds totals. It has a top rule and a muted background.
| Source | Alerts | Events |
|---|---|---|
| Endpoint (EDR) | 412 | 98,234 |
| Identity provider | 167 | 20,311 |
| Email gateway | 89 | 4,872 |
| Total | 668 | 123,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.
| Asset | Operating system | Owner | |
|---|---|---|---|
| srv-web-02 | Ubuntu 22.04 | Infrastructure | |
| vm-jump-01 | Windows Server 2019 | IT operations | |
| wks-0193 | Windows 11 | Finance |
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.
| Event | Time | Source | Bytes |
|---|---|---|---|
| EVT-90412 | 10/23/2026 14:59:07 | firewall-edge-01 | 1,200 |
| EVT-90411 | 10/23/2026 14:57:07 | vpn-gw-02 | 1,571 |
| EVT-90410 | 10/23/2026 14:55:07 | dc-01 | 1,942 |
| EVT-90409 | 10/23/2026 14:53:07 | proxy-03 | 2,313 |
| EVT-90408 | 10/23/2026 14:51:07 | firewall-edge-01 | 2,684 |
| EVT-90407 | 10/23/2026 14:49:07 | vpn-gw-02 | 3,055 |
| EVT-90406 | 10/23/2026 14:47:07 | dc-01 | 3,426 |
| EVT-90405 | 10/23/2026 14:45:07 | proxy-03 | 3,797 |
| EVT-90404 | 10/23/2026 14:43:07 | firewall-edge-01 | 4,168 |
| EVT-90403 | 10/23/2026 14:41:07 | vpn-gw-02 | 4,539 |
| EVT-90402 | 10/23/2026 14:39:07 | dc-01 | 4,910 |
| EVT-90401 | 10/23/2026 14:37:07 | proxy-03 | 5,281 |
| EVT-90400 | 10/23/2026 14:35:07 | firewall-edge-01 | 5,652 |
| EVT-90399 | 10/23/2026 14:33:07 | vpn-gw-02 | 6,023 |
| EVT-90398 | 10/23/2026 14:31:07 | dc-01 | 6,394 |
| EVT-90397 | 10/23/2026 14:29:07 | proxy-03 | 6,765 |
| EVT-90396 | 10/23/2026 14:27:07 | firewall-edge-01 | 7,136 |
| EVT-90395 | 10/23/2026 14:25:07 | vpn-gw-02 | 7,507 |
| EVT-90394 | 10/23/2026 14:23:07 | dc-01 | 7,878 |
| EVT-90393 | 10/23/2026 14:21:07 | proxy-03 | 8,249 |
| EVT-90392 | 10/23/2026 14:19:07 | firewall-edge-01 | 8,620 |
| EVT-90391 | 10/23/2026 14:17:07 | vpn-gw-02 | 8,991 |
| EVT-90390 | 10/23/2026 14:15:07 | dc-01 | 9,362 |
| EVT-90389 | 10/23/2026 14:13:07 | proxy-03 | 9,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.
| Alert | Asset | Severity | Events |
|---|---|---|---|
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, oraria-labeloraria-labelledbyonTablepointing at the card title above it. -
TableHeadrenders<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-labelthat 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.
| Prop | Type | Default |
|---|---|---|
containerClassName | stringClasses 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 | stringAlready-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 |
|---|---|---|
sticky | booleanKeeps 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.