Table

Presentational parts over a plain <table>. Four separator styles, two densities, a sortable column header, a fixed selection column, and the cell shapes a data table repeats most. Nothing here holds state — sorting, selection and paging stay the caller's, so a headless table layer can be dropped on top later without restyling any of this.

tsx
import {
  Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell,
  TableSortHeader, TableSelectHead, TableSelectCell,
  TableCellStack, TableActions, TableEmpty, TableSkeletonRows,
} from "@pestoui/core"

Runs table

AgentStatusTotal runs
Niche Content ComposerSuccess1,204
Probate & FSBO Lead HunterRunning863
Inbox Triage AssistantFailed97

Separators

How rows are told apart. row is the default hairline under every row; zebra alternates a faint fill instead; grid adds vertical rules for dense numeric columns; none removes the divisions entirely and leaves whitespace to do the work. Zebra re-states hover, the expanded hold and the selected fill at its own specificity, so a striped row still lights up under the pointer — a stripe that goes dead on hover is the usual bug here.

separators="row"

AgentStatusTotal runs
Niche Content ComposerSuccess1,204
Probate & FSBO Lead HunterRunning863
Inbox Triage AssistantFailed97

separators="zebra"

AgentStatusTotal runs
Niche Content ComposerSuccess1,204
Probate & FSBO Lead HunterRunning863
Inbox Triage AssistantFailed97

separators="grid"

AgentStatusTotal runs
Niche Content ComposerSuccess1,204
Probate & FSBO Lead HunterRunning863
Inbox Triage AssistantFailed97

separators="none"

AgentStatusTotal runs
Niche Content ComposerSuccess1,204
Probate & FSBO Lead HunterRunning863
Inbox Triage AssistantFailed97

Density

The size axis moves padding and row height only — never font-size. A table that shrinks its own text stops matching the page around it. Density travels as three custom properties rather than per-part variants, so a cell can never disagree with the row it sits in, and size is deliberately left unset by default: an ancestor that declares those properties — a Table Card, for instance — becomes the single owner of density for everything inside it, and a plain Table simply inherits. Pass size on the table only to override a shell that disagrees.

AgentStatusTotal runs
Niche Content ComposerSuccess1,204
Inbox Triage AssistantFailed97

Sorting and selection

Both axes are controlled, and neither is implemented here. TableSortHeader renders the arrow, sets aria-sort and calls onSort with the direction you should move to — it does not sort anything. TableSelectHead and TableSelectCell only fix the column to a stable width so the checkbox stops sliding as the widest cell changes between pages; you supply the Checkbox, and select-all goes half-checked by passing checked="indeterminate". A selected row is marked with data-state="selected" and tints with the selected green, the same token the checkbox fills with. The idle sort chevron is held at zero opacity rather than unmounted, so the column keeps its width whether or not the pointer is over it.

Agent
Niche Content Composercontent@example.com
1,204
Probate & FSBO Lead Huntersales@example.com
863
Inbox Triage Assistantops@example.com
97

Cell layout

TableCellStack is the one cell shape worth owning — a bold primary over a muted secondary, with an optional leading media slot. Every other pattern inside a cell (a status badge, a progress bar, an avatar group, a rating) is an existing component dropped straight into a TableCell, and wrapping those would only duplicate their props. TableActions holds the trailing icon buttons and is visible at rest on purpose: actions that fade in on row hover are undiscoverable, and on a touch screen unreachable.

AgentStatusActions
Niche Content Composercontent@example.com
Success
Inbox Triage Assistantops@example.com
Failed
2 agents1,301 runs

A sticky header needs a bounded scroller to stick against, and that scroller is the container every table already renders inside — reach it through containerProps. The header hairline is drawn by a pseudo-element rather than a border here: a collapsed border belongs to the table rather than the cell, so it would stay behind with the body and the header would visibly shed its underline mid-scroll.

RunStartedDuration
Run #10421h ago12s
Run #10412h ago15s
Run #10403h ago18s
Run #10394h ago21s
Run #10385h ago24s
Run #10376h ago27s
Run #10367h ago30s
Run #10358h ago33s
Run #10349h ago36s
Run #103310h ago39s

Empty and loading

TableEmpty renders the row as well as the cell, because the row is the part that keeps getting written wrong: an empty state that inherits the row hover fill reads as a clickable record. Drop a small EmptyState inside it. TableSkeletonRows takes one entry per column and offsets each row against the stagger token, turning a block pulsing in unison — which reads as one big flashing rectangle — into a sweep travelling down the table. Set aria-busy on the Table while they are up; the placeholder rows are hidden from assistive tech, since reading out thirty-five empty cells is worse than silence.

AgentStatusTotal runs
No runs yet
This agent has never been triggered. Start it once and its history shows up here.
AgentStatusTotal runs

Props

PropTypeDefaultDescription
size"default" | "sm"—Density. Sets cell padding and header height through three custom properties; deliberately has no default so an ancestor such as a Table Card can own density for the whole shell and a bare Table inherits it. Never changes font-size.
separators"row" | "zebra" | "grid" | "none""row"How rows are divided. zebra re-declares hover, the expanded hold and selected at its own specificity so striped rows stay live under the pointer.
containerPropsReact.ComponentProps<"div">—Escape hatch to the scroll container every table renders inside. Required for a sticky header, which needs a bounded scroller — e.g. { className: "max-h-96 overflow-y-auto" }.
TableHeader stickybooleanfalsePins the header while the body scrolls. Needs the bounded container above; sticking to the page viewport will not work inside a Table Card, which clips its own overflow.
TableSortHeader direction"asc" | "desc" | nullnullControlled. null means this column is not the sorted one; the component renders the arrow and sets aria-sort, it never decides the direction.
TableSortHeader onSort(next: "asc" | "desc") => void—Fired with the direction the caller should move to — the inverse of the current one, or asc from unsorted.
TableSortHeader align"start" | "end""start"Which edge the label sits against, and which side the arrow takes. Use end for numeric columns so the header lines up with right-aligned figures.
TableEmpty colSpannumber—Required, and must match the real column count — nothing can validate it, and a wrong number silently breaks the grid.
TableSkeletonRows rowsnumber5How many placeholder rows to draw.
TableSkeletonRows columns{ width?: string; className?: string }[]—One entry per column; length must match the header row. width is a Tailwind utility — keep it close to the real content or the table jumps when data lands.
TableCellStack primaryReactNode—The bold first line — name, id, product.
TableCellStack secondaryReactNode—Muted second line below it — email, agent, sku. Omit for a single-line cell.
TableCellStack mediaReactNode—Slot on the inline-start side: an avatar, a logo, an icon tile.

Pesto UI · 117 documented components