Skip to content
Primitiv home
Framework
Consumption mode

Data Table

stableSource

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.

Playground

Density

Preview

DeploymentStatusDuration
harmoni-engineSuccess142ms
docs-siteSuccess88ms
registry-apiFailed203ms
Size
Frame
import {  DataTable, DataTableToolbar, DataTableFooter, DataTableRegion,  DataTableControlCell, DataTableSortHeader,} from "@/components/ui/data-table";import {  Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableScrollArea,} from "@/components/ui/table";
<DataTable size="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.

Installation

npx primitiv add data-table

Import

import { DataTable } from "@/components/ui/data-table";

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

<DataTable>  <DataTableToolbar>    <DataTableRegion align="start">…</DataTableRegion>  </DataTableToolbar>  <TableScrollArea>    <Table>      <TableHead>        <TableRow>          <DataTableControlCell header />          <DataTableSortHeader direction="none" onSort={…}>…</DataTableSortHeader>        </TableRow>      </TableHead>      <TableBody>{/* rows */}</TableBody>    </Table>  </TableScrollArea>  <DataTableFooter>    <DataTableRegion align="end">{/* Pagination */}</DataTableRegion>  </DataTableFooter></DataTable>

Props

DataTable

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

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl"mdType scale for the table and its bars; data-density scales padding further.
frame"framed" | "bare"framedWhether the shell draws its own surface, hairline and radius.
sticky"false" | "true"falsePins 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.

PropTypeDefaultDescription
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.

PropTypeDefaultDescription
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.
headerboolean—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.

PropTypeDefaultDescription
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.
childrenReactNode—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.

PropTypeDefaultDescription
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.

PropTypeDefaultDescription
asChildbooleanfalseRender 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.

PropTypeDefaultDescription
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).
cellPropsTdHTMLAttributes<HTMLTableCellElement>—Passed through to the <td> that spans the row, for styling the cell itself rather than the row.
childrenReactNode—The detail panel's content.
forceMountbooleanfalseKeep 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.
gutternumber0How 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

--primitiv-data-table-background--primitiv-data-table-border-color--primitiv-data-table-border-width--primitiv-data-table-radius--primitiv-data-table-rule-color--primitiv-data-table-header-background--primitiv-data-table-bar-padding-inline--primitiv-data-table-bar-padding-block--primitiv-data-table-bar-gap--primitiv-data-table-control-padding-inline--primitiv-data-table-visible-rows--primitiv-data-table-row-block-size--primitiv-data-table-header-block-size--primitiv-data-table-font-family--primitiv-data-table-font-size--primitiv-data-table-font-weight--primitiv-data-table-line-height--primitiv-data-table-icon-size--primitiv-data-table-sort-gap--primitiv-data-table-sort-hover-background--primitiv-data-table-sort-idle-color--primitiv-data-table-detail-padding-inline--primitiv-data-table-detail-padding-block--primitiv-data-table-transition-duration--primitiv-data-table-transition-easing--primitiv-data-table-focus-ring-color--primitiv-data-table-focus-ring-width

Data attributes

DataTableExpandTrigger

className: .primitiv-data-table__expand

AttributeValueWhen
data-stateopen | closedthe row's detail panel is revealed or hidden

DataTableDetailRow

className: .primitiv-data-table__detail-row

AttributeValueWhen
data-stateopen | closedthe 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

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.

Density
EnvironmentStatus
harmoni-engineProductionSuccess142ms
docs-sitePreviewSuccess88ms
registry-apiProductionFailed203ms
tokens-cdnProductionSuccess54ms
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.<DataTable size="sm">  <DataTableToolbar>    <DataTableRegion align="start">      <Input value={filter} onChange={…} placeholder="Filter deployments..." />    </DataTableRegion>  </DataTableToolbar>
  <TableScrollArea>    <Table size="sm">      <TableHead>        <TableRow>          <DataTableControlCell header>            <Checkbox checked={headerChecked} onCheckedChange={toggleAll} aria-label="Select all rows" />          </DataTableControlCell>          <DataTableSortHeader direction={dir("name")} onSort={() => toggleSort("name")}>            Deployment          </DataTableSortHeader>          <DataTableSortHeader align="end" direction={dir("duration")} onSort={() => toggleSort("duration")}>            Duration          </DataTableSortHeader>        </TableRow>      </TableHead>      <TableBody>        {rows.map((row) => (          <TableRow key={row.id} aria-selected={selected.has(row.id)}>            <DataTableControlCell>              <Checkbox checked={selected.has(row.id)} onCheckedChange={…} aria-label={`Select ${row.name}`} />            </DataTableControlCell>            <TableCell>{row.name}</TableCell>            <TableCell align="end">{row.duration}ms</TableCell>          </TableRow>        ))}      </TableBody>    </Table>  </TableScrollArea>
  <DataTableFooter>    <DataTableRegion align="start">{selected.size} selected</DataTableRegion>    <DataTableRegion align="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.

Density
DeploymentEnvironmentDuration
harmoni-engineProduction142ms
docs-sitePreview88ms
registry-apiProduction203ms
tokens-cdnProduction54ms
figma-syncPreview176ms
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>    <DataTableControlCell header>      <Checkbox checked={allChecked} onCheckedChange={toggleAll} aria-label="Select all rows" />    </DataTableControlCell>    <TableHeader>Deployment</TableHeader>  </TableRow></TableHead><TableBody>  {rows.map((row) => (    <TableRow key={row.id} aria-selected={selected.has(row.id)}>      <DataTableControlCell>        <Checkbox          checked={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.

Density
DeploymentEnvironmentStatusDuration
harmoni-engineProductionSuccess142ms
docs-sitePreviewSuccess88ms
registry-apiProductionFailed203ms
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>    <Table aria-rowcount={total}>{/* just this page's rows */}</Table>  </TableScrollArea>  <DataTableFooter>    <DataTableRegion align="start">Showing {start}–{end} of {total}</DataTableRegion>    <DataTableRegion align="end">      <Pagination label="Pages" size="sm">        <PaginationList>          <PaginationItem>            <PaginationPrevious disabled={page === 0} onClick={() => setPage(page - 1)} />          </PaginationItem>          {pages.map((i) => (            <PaginationItem key={i}>              <PaginationLink isActive={i === page} onClick={() => setPage(i)}>{i + 1}</PaginationLink>            </PaginationItem>          ))}          <PaginationItem>            <PaginationNext disabled={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.

Density
DetailsDeploymentStatusDuration
harmoni-engineSuccess142ms
docs-siteSuccess88ms
registry-apiFailed203ms
registry-apiProduction · finished in 203ms · triggered by a push to main.
tokens-cdnSuccess54ms
import { Table } from "@primitiv-ui/react";import {  DataTableControlCell, DataTableExpandTrigger, DataTableDetailRow,} from "@/components/ui/data-table";
<Table.Expandable expanded={open} onExpandedChange={setOpen}>  <TableRow>    <DataTableControlCell>      <DataTableExpandTrigger aria-label={`Show details for ${row.name}`} />    </DataTableControlCell>    <TableCell>{row.name}</TableCell>  </TableRow>  <DataTableDetailRow colSpan={4} gutter={1} forceMount>    <DeploymentDetail row={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.

Density
DeploymentEnvironmentStatusDuration
harmoni-engineProductionSuccess142ms
docs-sitePreviewSuccess88ms
registry-apiProductionFailed203ms
tokens-cdnProductionSuccess54ms
figma-syncPreviewBuilding176ms
primitiv-cliProductionSuccess121ms
marketing-webPreviewSuccess67ms
auth-gatewayProductionFailed245ms
import {  DataTable, DataTableToolbar, DataTableFooter, DataTableRegion,  DataTableControlCell, DataTableSortHeader,} from "@/components/ui/data-table";import {  Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableScrollArea,} from "@/components/ui/table";
<DataTable sticky>  <TableScrollArea style={{ 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.

Density

No deployments

Nothing matches this filter — try a broader search.

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 { EmptyState, EmptyStateTitle, EmptyStateDescription } from "@/components/ui/empty-state";
<DataTable>  <DataTableToolbar>{/* filter stays reachable */}</DataTableToolbar>  {rows.length === 0 ? (    <EmptyState>      <EmptyStateTitle>No deployments</EmptyStateTitle>      <EmptyStateDescription>Nothing matches this filter.</EmptyStateDescription>    </EmptyState>  ) : (    <TableScrollArea>{/* … */}</TableScrollArea>  )}</DataTable>