A framed data-table shell — a toolbar, a table and a footer that read as one object, plus the anatomy sorting, selection and expandable rows need. Owns no state: sorting, selection, expansion, filtering and pagination all arrive as props, so an external table engine (TanStack Table) can drive it.
Registry-only
No headless primitive — this component ships only as a copied styled file, so there is no Headless mode. primitiv add data-table installs it whichever mode you are reading.
Playground
Preview
Deployment
Status
Duration
harmoni-engine
Success
142ms
docs-site
Success
88ms
registry-api
Failed
203ms
import{DataTable,DataTableToolbar,DataTableFooter,DataTableRegion,DataTableControlCell,DataTableSortHeader,}from"@/components/ui/data-table";import{Table,TableHead,TableBody,TableRow,TableHeader,TableCell,TableScrollArea,}from"@/components/ui/table";<DataTablesize="md"frame="framed"><DataTableToolbar>{/* filter, field menu */}</DataTableToolbar><TableScrollArea><Table>{/* Table.Head / Table.Body — the table component */}</Table></TableScrollArea><DataTableFooter>{/* selection summary, Pagination */}</DataTableFooter></DataTable>
Density is set by a data-density ancestor — the Context system, not a DataTable prop.
No headless primitive — this one ships only as a copied styled surface, so primitiv add is the only way in, whichever mode you are reading.
Anatomy
The shell is DataTable wrapping three things: a DataTableToolbar (with DataTableRegions that align content start/center/end), the Table itself (the table component — never restyled here) inside a TableScrollArea, and a DataTableFooter. The interactive anatomy lives in cells: DataTableControlCell holds a checkbox or the expand trigger; DataTableSortHeader is a sortable <th> (it wires aria-sort and composes a DataTableSortButton); DataTableExpandTrigger + DataTableDetailRow (inside a headless Table.Expandable) make a disclosure row. There is deliberately no data model — you bring your own rows.
Generated from the copied file’s props type and contract.json. Never hand-maintained.
DataTable
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
size
"xs" | "sm" | "md" | "lg" | "xl"
md
Type scale for the table and its bars; data-density scales padding further.
frame
"framed" | "bare"
framed
Whether the shell draws its own surface, hairline and radius.
sticky
"false" | "true"
false
Pins the header row while the body scrolls. Adds an opaque header background, which the base Table deliberately does not have.
DataTableToolbar
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
No props of its own.
DataTableFooter
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
No props of its own.
DataTableRegion
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
align
"start" | "center" | "end"
"start"
Which of the bar's three regions this is. Every control is independently
placeable, so the filter can sit at the start, the centre or the end, and
so can the field menu.
The bar is a minmax(0, 1fr) auto minmax(0, 1fr) grid: equal fractional
flanks are the only thing that holds a centre still when the two sides hold
different amounts.
DataTableControlCell
Extends HTMLTableCellElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
align
"start" | "center" | "end"
—
Cell text alignment. A control cell centres its content by default via its
own class, so this is only for overriding that.
header
boolean
—
Render a <th> instead of a <td> — for the select-all cell in the head.
DataTableSortHeader
Extends HTMLTableCellElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
align
"start" | "center" | "end"
—
Match the column's text alignment — end for numeric columns. Forwarded to
the <th> as well as the sort button, so the header and its cells agree.
children
ReactNode
—
The visible column label.
direction
"none" | "asc" | "desc"
—
The column's current sort state, mirrored onto the <th>'s aria-sort.
onSort
() => void
—
Fires when the header is activated — hand it the engine's sort toggler.
DataTableSortButton
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
align
"start" | "center" | "end"
"start"
Match the column's text alignment — end for numeric columns.
direction
"none" | "asc" | "desc"
"none"
The column's current sort state. none still reserves space for the glyph,
so the label does not shift when sorting starts.
DataTableExpandTrigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
asChild
boolean
false
Render a consumer-supplied control instead of the native <button>, with
this part's aria-expanded, aria-controls, data-state and click
handling merged onto it via the Slot pattern.
This is how a styled layer composes a real Button rather than re-creating
one: the registry data-table renders a ghost Button through it, matching
the Figma set, whose Expand Trigger is an Icon Button instance rather than a
drawn glyph. The child must be a single element that accepts a ref, and it
should be a <button> — the wiring assumes a control.
DataTableDetailRow
Extends HTMLTableRowElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
colSpan* (required)
number
—
How many columns the panel spans — every column the table renders,
control columns included.
Deliberately a required prop rather than a value counted from context:
counting would mean the table registering its own columns, and an
external table engine already knows the number (TanStack Table:
table.getVisibleLeafColumns().length).
cellProps
TdHTMLAttributes<HTMLTableCellElement>
—
Passed through to the <td> that spans the row, for styling the cell
itself rather than the row.
children
ReactNode
—
The detail panel's content.
forceMount
boolean
false
Keep the row mounted while collapsed so a CSS transition can run.
Off by default, and that default matters more here than it does on a
<div>-based disclosure: a mounted row is still a row, so assistive
technology counts it, and a table of 24 rows with 24 force-mounted
detail rows announces 48. Opt in only when you are animating, and note
that the collapsed row remains in the accessibility tree while you do.
gutter
number
0
How many leading columns to leave empty before the panel starts — the
control columns (select, expand) a row renders in front of its data.
The panel then spans the columns that remain, so colSpan keeps its
meaning: the table's full column count, gutter included.
This is how a panel aligns with the first data column instead of the
table's edge (a panel starting under the checkbox reads as a new section of
the table rather than an opened row). Alignment by empty cell rather than by
a CSS indent is deliberate: the browser's own table layout resolves the
width, so it stays exact across every size, density and control-column set
with nothing measured and no arithmetic to drift.
Styling contract
27 CSS custom properties on .primitiv-data-table — mode-agnostic. These names are the stable surface; the values are not.
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
DataTableExpandTrigger
className:.primitiv-data-table__expand
Attribute
Value
When
data-state
open | closed
the row's detail panel is revealed or hidden
DataTableDetailRow
className:.primitiv-data-table__detail-row
Attribute
Value
When
data-state
open | closed
the panel is revealed or hidden
Accessibility
Every checkbox names its row. A row's checkbox needs a name that identifies that row (Select harmoni-engine), and the header's is Select all rows — a column of identically-named checkboxes is unusable in a screen reader's element list.
aria-selected on the row is a styling hook only. The checkbox's own checked state is what assistive technology reports reliably; don't rely on aria-selected to convey selection to AT.
Sort state goes on the <th>, one column at a time.DataTableSortHeader sets aria-sort (ascending/descending/none) for you — only one column should be sorted, and therefore only one aria-sort other than none, at a time.
The expander's state lives on the button, not the row. A row's aria-expanded is only reliably announced inside a role="treegrid"; DataTableExpandTrigger carries aria-expanded / aria-controls itself. This is a disclosure holding a detail panel — expanding into hierarchical child rows is a treegrid, a different pattern and out of scope.
Give the empty control-column headers a name. The <th> over the checkbox and expander columns has no visible text, so give it a visually-hidden label — an unlabelled column header reads as a gap.
Paginating? Add aria-rowcount to the table. Otherwise assistive technology announces the current page's row count as the whole table's. Pagination is your engine's job; the shell only lays the controls out.
No data model, by design. There is no columns/data prop — you bring the rows as ordinary Table markup, and column visibility, grouping, colSpan and footer aggregates stay plain JSX rather than new props. Toolbar controls (a field menu, filters) are yours to compose from dropdown / input.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
A complete data table
Everything at once, driven by plain useState — filter the toolbar, sort the Deployment and Duration columns (click to cycle ascending → descending → off), select rows with the checkboxes, and page through with the footer. The shell owns none of that state: it renders the toolbar/table/footer anatomy and forwards every interaction to your handlers, so you could swap the useState here for TanStack Table without touching the markup.
Environment
Status
harmoni-engine
Production
Success
142ms
docs-site
Preview
Success
88ms
registry-api
Production
Failed
203ms
tokens-cdn
Production
Success
54ms
import{ useState }from"react";import{DataTable,DataTableToolbar,DataTableFooter,DataTableRegion,DataTableControlCell,DataTableSortHeader,}from"@/components/ui/data-table";import{Table,TableHead,TableBody,TableRow,TableHeader,TableCell,TableScrollArea,}from"@/components/ui/table";import{Checkbox}from"@/components/ui/checkbox";import{Input}from"@/components/ui/input";import{Pagination,/* … */}from"@/components/ui/pagination";// filter / sort / selection / page all live in your state.<DataTablesize="sm"><DataTableToolbar><DataTableRegionalign="start"><Inputvalue={filter}onChange={…}placeholder="Filter deployments..."/></DataTableRegion></DataTableToolbar><TableScrollArea><Tablesize="sm"><TableHead><TableRow><DataTableControlCellheader><Checkboxchecked={headerChecked}onCheckedChange={toggleAll}aria-label="Select all rows"/></DataTableControlCell><DataTableSortHeaderdirection={dir("name")}onSort={()=>toggleSort("name")}> Deployment</DataTableSortHeader><DataTableSortHeaderalign="end"direction={dir("duration")}onSort={()=>toggleSort("duration")}> Duration</DataTableSortHeader></TableRow></TableHead><TableBody>{rows.map((row)=>(<TableRowkey={row.id}aria-selected={selected.has(row.id)}><DataTableControlCell><Checkboxchecked={selected.has(row.id)}onCheckedChange={…}aria-label={`Select ${row.name}`}/></DataTableControlCell><TableCell>{row.name}</TableCell><TableCellalign="end">{row.duration}ms</TableCell></TableRow>))}</TableBody></Table></TableScrollArea><DataTableFooter><DataTableRegionalign="start">{selected.size} selected</DataTableRegion><DataTableRegionalign="end">{/* <Pagination> */}</DataTableRegion></DataTableFooter></DataTable>
Row selection
Selection on its own. A DataTableControlCell header holds a select-all Checkbox — checked is true / false / "indeterminate" from your state — and each row a DataTableControlCell with its own. Put aria-selected on the TableRow to get the selected-row highlight; it is a styling hook only, so the checkbox's checked state stays the real source of truth for assistive technology. Name every row checkbox for its row (Select docs-site), or a column of identical “Select” labels is unusable in a screen reader.
Deployment
Environment
Duration
harmoni-engine
Production
142ms
docs-site
Preview
88ms
registry-api
Production
203ms
tokens-cdn
Production
54ms
figma-sync
Preview
176ms
import{DataTable,DataTableToolbar,DataTableFooter,DataTableRegion,DataTableControlCell,DataTableSortHeader,}from"@/components/ui/data-table";import{Table,TableHead,TableBody,TableRow,TableHeader,TableCell,TableScrollArea,}from"@/components/ui/table";import{Checkbox}from"@/components/ui/checkbox";<TableHead><TableRow><DataTableControlCellheader><Checkboxchecked={allChecked}onCheckedChange={toggleAll}aria-label="Select all rows"/></DataTableControlCell><TableHeader>Deployment</TableHeader></TableRow></TableHead><TableBody>{rows.map((row)=>(<TableRowkey={row.id}aria-selected={selected.has(row.id)}><DataTableControlCell><Checkboxchecked={selected.has(row.id)}onCheckedChange={(c)=>toggleRow(row.id, c)}aria-label={`Select ${row.name}`}/></DataTableControlCell><TableCell>{row.name}</TableCell></TableRow>))}</TableBody>
Pagination
Paging lives in the DataTableFooter — a DataTableRegion for a “showing X–Y of N” summary, and another holding the Pagination component. The shell owns no page state: you slice your own rows and drive Pagination from your page value. One accessibility must: set aria-rowcount on the Table to the full row count, or assistive technology announces the current page's rows as the whole table.
Deployment
Environment
Status
Duration
harmoni-engine
Production
Success
142ms
docs-site
Preview
Success
88ms
registry-api
Production
Failed
203ms
import{DataTable,DataTableToolbar,DataTableFooter,DataTableRegion,DataTableControlCell,DataTableSortHeader,}from"@/components/ui/data-table";import{Table,TableHead,TableBody,TableRow,TableHeader,TableCell,TableScrollArea,}from"@/components/ui/table";import{Pagination,PaginationList,PaginationItem,PaginationLink,PaginationPrevious,PaginationNext}from"@/components/ui/pagination";<DataTable><TableScrollArea><Tablearia-rowcount={total}>{/* just this page's rows */}</Table></TableScrollArea><DataTableFooter><DataTableRegionalign="start">Showing {start}–{end} of {total}</DataTableRegion><DataTableRegionalign="end"><Paginationlabel="Pages"size="sm"><PaginationList><PaginationItem><PaginationPreviousdisabled={page ===0}onClick={()=>setPage(page -1)}/></PaginationItem>{pages.map((i)=>(<PaginationItemkey={i}><PaginationLinkisActive={i === page}onClick={()=>setPage(i)}>{i +1}</PaginationLink></PaginationItem>))}<PaginationItem><PaginationNextdisabled={page === last}onClick={()=>setPage(page +1)}/></PaginationItem></PaginationList></Pagination></DataTableRegion></DataTableFooter></DataTable>
Expandable rows
A disclosure row: a DataTableExpandTrigger in a control cell reveals a DataTableDetailRow beneath. The expanded state is yours — the pairing is a headless Table.Expandable (the one seam this shell reaches into @primitiv-ui/react for), which wires the trigger's aria-expanded / aria-controls to the detail row. The panel's gutter={1} clears the one control column so it aligns under the first data column, not the table's edge; forceMount lets it animate open.
Details
Deployment
Status
Duration
harmoni-engine
Success
142ms
harmoni-engineProduction · finished in 142ms · triggered by a push to main.
docs-site
Success
88ms
docs-sitePreview · finished in 88ms · triggered by a push to main.
registry-api
Failed
203ms
registry-apiProduction · finished in 203ms · triggered by a push to main.
tokens-cdn
Success
54ms
tokens-cdnProduction · finished in 54ms · triggered by a push to main.
import{Table}from"@primitiv-ui/react";import{DataTableControlCell,DataTableExpandTrigger,DataTableDetailRow,}from"@/components/ui/data-table";<Table.Expandableexpanded={open}onExpandedChange={setOpen}><TableRow><DataTableControlCell><DataTableExpandTriggeraria-label={`Show details for ${row.name}`}/></DataTableControlCell><TableCell>{row.name}</TableCell></TableRow><DataTableDetailRowcolSpan={4}gutter={1}forceMount><DeploymentDetailrow={row}/></DataTableDetailRow></Table.Expandable>
Sticky header
sticky pins the header while the body scrolls — give the TableScrollArea a max-block-size so there is something to scroll within. It also reserves the scrollbar gutter so columns don't shift as rows filter in and out, which is why you should pass it only when the body actually scrolls (sticky={rows.length > 0}) — pinning a header over a body that doesn't scroll just leaves an empty strip down the edge.
Deployment
Environment
Status
Duration
harmoni-engine
Production
Success
142ms
docs-site
Preview
Success
88ms
registry-api
Production
Failed
203ms
tokens-cdn
Production
Success
54ms
figma-sync
Preview
Building
176ms
primitiv-cli
Production
Success
121ms
marketing-web
Preview
Success
67ms
auth-gateway
Production
Failed
245ms
import{DataTable,DataTableToolbar,DataTableFooter,DataTableRegion,DataTableControlCell,DataTableSortHeader,}from"@/components/ui/data-table";import{Table,TableHead,TableBody,TableRow,TableHeader,TableCell,TableScrollArea,}from"@/components/ui/table";<DataTablesticky><TableScrollAreastyle={{ maxBlockSize:"16rem"}}><Table>{/* a head + many rows */}</Table></TableScrollArea></DataTable>
Empty state
When there are no rows — nothing yet, or a filter that matched nothing — swap the table for an EmptyState inside the shell, so the toolbar and frame stay put and only the body changes. The shell composes empty-state; keep the toolbar mounted so the control that clears the filter is still reachable.
No deployments
Nothing matches this filter — try a broader search.