Skip to content
Primitiv home
Framework
Consumption mode

Table

stableSource Figma

An accessible data table — a compound of standard HTML table elements (<table>, <thead>, <tbody>, <tr>, <th>, <td>, <caption>) with a tokenised default theme.

Playground

Density

Preview

ProjectOwnerCommits
Design systemAda L.128
Docs siteGrace H.342
EngineAlan T.87
Size
Rows
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableCaption } from "@/components/ui/table";
<Table size="md" rows="plain">  <TableHead>    <TableRow>      <TableHeader>Project</TableHeader>      <TableHeader>Commits</TableHeader>    </TableRow>  </TableHead>  <TableBody>    {rows.map((r) => (      <TableRow key={r.id}>        <TableCell>{r.project}</TableCell>        <TableCell>{r.commits}</TableCell>      </TableRow>    ))}  </TableBody></Table>

Density is set by a data-density ancestor — the Context system, not a Table prop.

Installation

npx primitiv add table

Import

import { Table } from "@/components/ui/table";

Copied into your project as .primitiv-table — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<Table>  <TableCaption>…</TableCaption>  <TableHead>    <TableRow>      <TableHeader>…</TableHeader>    </TableRow>  </TableHead>  <TableBody>    <TableRow>      <TableCell>…</TableCell>    </TableRow>  </TableBody></Table>

Props

Table.Root

Extends HTMLTableElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledType scale for the whole table; data-density scales cell padding further.
rows"plain" | "striped"plainstyledWhether body rows alternate a subtle stripe for readability.

Table.Head

Extends HTMLTableSectionElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Table.Body

Extends HTMLTableSectionElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Extends HTMLTableSectionElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Table.Row

Extends HTMLTableRowElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Table.Header

Extends HTMLTableCellElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
align"start" | "center" | "end"startstyledColumn text alignment, matching Figma's Table / Header Cell Align axis. Set it to match the column's cells — end for numeric columns. Direction-aware — start/end follow the reading direction, flipping under RTL.

Table.Cell

Extends HTMLTableCellElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
align"start" | "center" | "end"startstyledCell text alignment, matching Figma's Table / Cell Align axis. CSS cannot align a whole column (text-align does not apply to <col>), so set this on every cell in the column. Direction-aware — start/end follow the reading direction, flipping under RTL.

Table.ScrollArea

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
children (required)ReactNode—headlessThe table (typically a single TableRoot) to wrap in the horizontally-scrolling container.
styleCSSProperties—headlessMerged with (and taking priority over) the base scroll styles (display: block, overflow-x: auto, max-width: 100%), so additional styles can be layered on without repeating the scroll declarations. See TableScrollArea's custom-styles example.

Table.Caption

Extends HTMLTableCaptionElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
children (required)ReactNode—headlessThe visible, accessible label for the table. Preferred over aria-label/aria-labelledby on the table itself — the browser programmatically associates a <caption> with its <table>.
captionSide"top" | "bottom""bottom"headlessControls the CSS caption-side property, positioning the caption above or below the table. See TableCaption's placement examples. - "bottom" — caption appears below the table. - "top" — caption appears above the table.

Styling contract

--primitiv-table-font-family--primitiv-table-font-size--primitiv-table-font-weight--primitiv-table-line-height--primitiv-table-foreground--primitiv-table-rule-color--primitiv-table-header-foreground--primitiv-table-header-weight--primitiv-table-header-rule-color--primitiv-table-footer-rule-color--primitiv-table-caption-font-family--primitiv-table-caption-font-size--primitiv-table-caption-font-weight--primitiv-table-caption-line-height--primitiv-table-caption-foreground--primitiv-table-transition-duration--primitiv-table-transition-easing

Data attributes

TableRow

className: .primitiv-table__row

AttributeValueWhen
data-stateexpanded | collapsedwhether this row's detail row is open

Accessibility

  • Name the table. A Table.Caption (or an aria-label on the root) gives the table an accessible name, so a screen-reader user landing on it knows what it holds before reading cells. Table.Caption also takes captionSide to place it visually above or below while staying the table's caption semantically.
  • Header cells are <th>, and they carry scope. Table.Header renders a real <th>; column headers should be scope="col" (the default for a head-row header) and a row's leading label cell scope="row", so assistive technology can announce the row and column a cell belongs to. Use header cells for headers — never a styled <td>.
  • align is visual only. It aligns the text; it changes nothing in the accessibility tree, and it is per-cell because CSS cannot align a whole column — set it on the header and every cell alike, or the column won't line up.
  • A wide table needs a keyboard-scrollable region. Table.ScrollArea should be focusable (tabIndex={0}) and labelled (aria-label) so a keyboard user can scroll to the off-screen columns — a scroll region reachable only by mouse hides data from them.
  • Striped rows are decoration. rows="striped" conveys nothing to assistive technology; do not lean on the shading to communicate state (a failed row, say) — put that in the cell's content.
  • Need expandable/disclosure rows? That lives in the headless Table primitive (Table.Expandable / Table.ExpandTrigger / Table.DetailRow), which wires the trigger's aria-expanded / aria-controls to the detail row. The styled registry surface documented here doesn't include it — reach for @primitiv-ui/react directly when you need it.
  • Don't build tables out of layout primitives. A grid of Box/Stack looks like a table but reads as ungrouped text — no row/column relationships, no navigation. Use the real Table parts whenever the data is genuinely tabular.

Examples

A basic table

The core shape: a Table.Head of Table.Header cells over a Table.Body of Table.Rows and Table.Cells, with a Table.Caption naming the whole thing. The parts render the real <table> / <thead> / <th> / <td> elements, so the browser and assistive technology get a genuine data table — row and column relationships and all — for free.

Density
Active projects
ProjectOwnerCommits
Design systemAda L.128
Docs siteGrace H.342
EngineAlan T.87
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableCaption } from "@/components/ui/table";
<Table>  <TableCaption>Active projects</TableCaption>  <TableHead>    <TableRow>      <TableHeader>Project</TableHeader>      <TableHeader>Commits</TableHeader>    </TableRow>  </TableHead>  <TableBody>    {rows.map((r) => (      <TableRow key={r.id}>        <TableCell>{r.project}</TableCell>        <TableCell>{r.commits}</TableCell>      </TableRow>    ))}  </TableBody></Table>

Striped rows

rows="striped" on the root shades alternate body rows, which helps the eye track across a wide or dense table. It is purely visual — the stripes carry no meaning and are not announced — so reach for it when scanning is hard, not as decoration on a three-row table.

Density
ProjectOwnerCommits
Design systemAda L.128
Docs siteGrace H.342
EngineAlan T.87
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableCaption } from "@/components/ui/table";
<Table rows="striped">  <TableHead>    <TableRow>      <TableHeader>Project</TableHeader>      <TableHeader>Commits</TableHeader>    </TableRow>  </TableHead>  <TableBody>    {rows.map((r) => (      <TableRow key={r.id}>        <TableCell>{r.project}</TableCell>        <TableCell>{r.commits}</TableCell>      </TableRow>    ))}  </TableBody></Table>

Column alignment

Numbers read best right-aligned, so their digits line up. align is a per-cell prop, not a column one — CSS text-align does not apply to a <col>, so set align="end" on the header and every cell in that column. start / end are direction-aware and flip under RTL, so a numeric column stays edge-aligned in either reading direction.

Density
ProjectOwnerCommits
Design systemAda L.128
Docs siteGrace H.342
EngineAlan T.87
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableCaption } from "@/components/ui/table";
<Table>  <TableHead>    <TableRow>      <TableHeader>Project</TableHeader>      <TableHeader align="end">Commits</TableHeader>    </TableRow>  </TableHead>  <TableBody>    {rows.map((r) => (      <TableRow key={r.id}>        <TableCell>{r.project}</TableCell>        <TableCell align="end">{r.commits}</TableCell>      </TableRow>    ))}  </TableBody></Table>

Wide tables: horizontal scroll

A table wider than its container should scroll rather than squeeze or overflow the page. Wrap it in Table.ScrollArea — a focusable, labelled scroll region — so a wide table pans horizontally on its own. Give the region an aria-label and it becomes keyboard-scrollable, so the columns past the edge are reachable without a mouse.

Density
ProjectOwnerStatusUpdatedCommits
Design systemAda L.Active2 days ago128
Docs siteGrace H.Active2 days ago342
EngineAlan T.Active2 days ago87
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableCaption } from "@/components/ui/table";
<TableScrollArea aria-label="Projects" tabIndex={0}>  <Table>{/* many columns */}</Table></TableScrollArea>