Composites

Data Table

The list screen of every Comitor app: a sticky-header table with sorting, row selection, pinned columns, column visibility, skeleton loading and an empty state. Every one of those is controlled — the component holds no data and calls no API, so the same table serves an in-memory array and a server-paginated endpoint without forking. The bar underneath it is Table Pagination.

Basic usage

Three required props carry the whole thing: columns (which is also the column order), rows, and getRowId, whose return value is both the React key and the selection key. Everything else is opt-in.

InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

Sorting

A sortable header cycles through ascending → descending → unsorted. The third stop is deliberate: it lets a user return to the order the server sent without reloading the page, which is why onSortChange is typed to receive null. The header button only appears when a column is sortable and the table has an onSortChange — a shared column definition can stay inert on a read-only screen.

Sort state: { columnId: 'client', direction: 'asc' }

InvoiceOwnerStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

The unsorted icon is not dimmed. It sits inside a button that is still operable, so opacity-50 would not be exempt from the 3:1 threshold of WCAG 1.4.11 — measured against the header row it comes out at 1.98:1 light and 2.23:1 dark. Left at text-muted-foreground it is 4.89:1 and 4.86:1. The sorted state stays distinguishable anyway: the button switches to text-foreground and the icon changes shape.

Row selection

selectable adds a 44px checkbox column pinned to the left. The selection itself is an array of getRowId values that you own. The header checkbox reports indeterminate when only some rows are selected, and select-all only ever touches rows that isRowSelectable allows — locked rows are never swept in and never swept out.

1 selectedPaid invoices are locked.
InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

Clicking a checkbox never triggers onRowClick: the cell wraps the checkbox in a span that stops propagation, so ticking a row does not also open it.

Column visibility

DataTableColumnToggle is a separate component so it can live on the page toolbar rather than inside the table. Both halves read the same visibleColumnIds state. Columns marked hideable: false — the identifying column, here Invoice — never appear in the menu, and the menu refuses to hide the last remaining column.

['id', 'client', 'owner', 'issued', 'amount', 'status']
InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

Why string[] and not Record<string, boolean>

  • It is a set, not an order. Column order always comes from columns; reordering this array changes nothing. The Ids suffix exists to say exactly that — the 0.1.0 name visibleColumns read like an array of column objects.
  • A record has a third state: key absent. Ship a new column and every saved layout is missing its key, so each call site guesses show or hide and the bug is silent. An array of ids is just JSON.stringify-able.
  • The package deliberately does not write to localStorage. In a multi-workspace SaaS a table layout belongs to the pair (user × workspace) and usually has to sync across machines — that is the application's call, not the component's.

Pinned columns and sticky header

Scroll the table below sideways and downwards. The identifier stays on the left, the amount on the right, and the header stays on top. width is required on every pinned column: the sticky left/right offsets are accumulated from those numbers, so a missing width stacks pinned columns on top of one another. The vertical scroll needs a height, which is what containerClassName="max-h-72" is doing here.

InvoiceClientOwnerContactIssuedTermsStatusAmount
INV-2041Aurora LogisticsMai Tran[email protected]Aug 2, 2026Net 30Paid$4,820
INV-2042Brightline MediaDuc Pham[email protected]Aug 4, 2026Net 30Pending$1,290
INV-2043Cormorant FoodsLinh Vo[email protected]Jul 28, 2026Net 30Overdue$7,640
INV-2044Delta ShipworksMai Tran[email protected]Aug 9, 2026Net 30Pending$980
INV-2045Everest AnalyticsHuy Nguyen[email protected]Aug 11, 2026Net 30Paid$15,400
INV-2046Foundry LabsLinh Vo[email protected]Aug 14, 2026Net 30Pending$2,310

Pinned cells take their fill from the row with bg-inherit, and CSS inherits background-color from the direct parent — which is why the header fill sits on the <tr>, not the <thead>. The same rule is the reason no state in this table is signalled with a translucent background: any alpha on the row is inherited by the pinned cell and the columns underneath show straight through it while you scroll.

Opening a row

Passing onRowClick puts the row into the tab order and wires Enter and Space, because opening a record must not be a mouse-only affordance. Tab into the table below and press Enter. Use isRowActive to mark whatever is currently open next to it.

InvoiceClientIssuedAmountStatus
INV-2041Aurora LogisticsAug 2, 2026$4,820Paid
INV-2042Brightline MediaAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksAug 9, 2026$980Pending
INV-2045Everest AnalyticsAug 11, 2026$15,400Paid
INV-2046Foundry LabsAug 14, 2026$2,310Pending
  • The row keeps its implicit role="row"; no role="button" is added. That role would override the row role and a screen reader would lose the table structure — row and column counts, column headers. The row stays a row and merely becomes activatable.
  • Key presses are only honoured when the <tr> itself has focus. Enter or Space on a checkbox, button or link inside a cell already means something else, and bubbling would open the record by accident.
  • The focus ring is drawn with outline and a negative offset, not ring: box-shadow is not painted on a <tr> in a border-collapse table, and a positive offset would be clipped by the scroll container. Its colour is foreground, not the brand ring — gold on card is about 1.9:1, under the 3:1 that WCAG 1.4.11 asks of a non-text indicator.
  • isRowActive paints an inset rule in --app-accent-ink on each cell (6.14:1 on card in light, 9.61:1 in dark). Not a background, for the two reasons above and below; not an outline on the row, because pinned cells sit on a higher stacking layer and would clip it. Because it is a shadow it adds no height, so opening a row does not make the table jump.

Density and stripes

density is a local override of the app-wide density axis, not a second class table. It rewrites --row-py and --table-head-h on the <table> element, which is what keeps the header shrinking together with the body — and lets one table be compact while the rest of the app stays comfortable.

The override only runs one way. Only density="compact" writes those two variables; the default "comfortable" writes nothing at all and simply inherits whatever <html data-density="compact"> says. A table can therefore be denser than the app around it, never looser.

InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

striped defaults to false: stripes help you track a row across a long, wide table and are pure noise on a short one, so it is a per-screen decision rather than a package default. The stripe uses the opaque --table-row-stripe token. It is declared at table level, so the generated selector is tbody tr:nth-child(even) at specificity (0,2,2) — it beats and replaces the row's own bg-card (0,1,0) rather than layering over it. A translucent stripe would therefore leave even rows with nothing opaque beneath them, and pinned cells inheriting that alpha would go see-through. The same specificity maths is why you cannot mark a row with a plain background class on a striped table: it loses on every even row.

Loading and empty states

loading replaces the body with loadingRowCount skeleton rows shaped like the real columns, and sets aria-busy on the table. When the request comes back empty, the built-in EmptyState uses labels.emptyTitle and labels.emptyDescription; pass empty when it needs an action.

InvoiceClientOwnerIssuedAmountStatus

EmptyState takes the icon component: icon={Inbox}, never icon={<Inbox />}. A Lucide icon is a forwardRef object rather than a function, so component and element cannot be told apart at runtime; the shared IconComponent type makes the compiler catch it instead of failing quietly. If you need an arbitrary node — an illustration — use the media slot.

The labels contract

Every string the package ships is Vietnamese, including the four aria-label strings that only a screen reader ever hears. An English app overrides them through labels — no fork, no wrapper. Three rules govern this bag, and they are the same across TablePaginationLabels, SearchFilterBarLabels and ShellLabels:

  • The prop is Partial<>. The component always spreads { ...DEFAULT_DATA_TABLE_LABELS, ...labels }, so overriding one key keeps the rest.
  • Keys that interpolate a number or a unit are functions, not strings with placeholders. DataTableLabels happens to have none — all seven keys are plain strings — but its sibling TablePaginationLabels does, and the reason is on that page.
  • A standalone prop beats labels. DataTableColumnToggle keeps a label prop because that string varies by content ("Fields", "Columns") while labels.columnToggle varies by language.
tsx
import { DataTableColumnToggle, DEFAULT_DATA_TABLE_LABELS } from '@comitor/ui'
import type { DataTableLabels } from '@comitor/ui'

// 1. The prop is Partial<DataTableLabels>. The component always spreads
//    { ...DEFAULT_DATA_TABLE_LABELS, ...labels }, so one key is a legal override.
<DataTableColumnToggle {...toggleProps} labels={{ columnToggle: 'Columns' }} />

// 2. A standalone prop beats `labels`. `label` is the per-table string, `labels.columnToggle`
//    is the per-language string — the first wins where both are present.
<DataTableColumnToggle {...toggleProps} label="Fields" labels={{ columnToggle: 'Columns' }} />
// renders "Fields"

// 3. Declare the full English set once and share it across every table in the app.
export const EN_TABLE_LABELS: Partial<DataTableLabels> = {
  selectAllRows: 'Select all visible rows',
  deselectAllRows: 'Clear the selection',
  selectRow: 'Select row',
  deselectRow: 'Deselect row',
  emptyTitle: 'No records yet',
  emptyDescription: 'This list is currently empty.',
  columnToggle: 'Columns',
}

// DEFAULT_DATA_TABLE_LABELS lives in a module with NO 'use client' on purpose, so a Server
// Component can read it — spread it, diff it, build a list from it.
const withOneChange = { ...DEFAULT_DATA_TABLE_LABELS, emptyTitle: 'Chưa có hoá đơn' }

DEFAULT_DATA_TABLE_LABELS and the DataTableLabels type live in a module that carries no 'use client' directive, on purpose. Everything exported from a client module reaches a Server Component as a client-reference proxy that reads back as undefined without throwing or warning — and plain data constants are exactly what a server page wants to spread, compare and list. The full set of keys and their Vietnamese defaults:

PropTypeDefaultDescription
selectAllRowsstring'Chọn tất cả dòng đang hiển thị'aria-label of the header checkbox while not everything is selected.
deselectAllRowsstring'Bỏ chọn tất cả'aria-label of the header checkbox once every selectable row is selected.
selectRowstring'Chọn dòng'aria-label of an unselected row checkbox.
deselectRowstring'Bỏ chọn dòng'aria-label of a selected row checkbox.
emptyTitlestring'Chưa có dữ liệu'Title of the built-in empty state. Ignored when you pass empty.
emptyDescriptionstring'Danh sách hiện đang trống.'Description of the built-in empty state.
columnTogglestring'Cột hiển thị'Button text and menu heading of DataTableColumnToggle.

Types

All of these are exported from @comitor/ui alongside the components.

ts
type SortDirection = 'asc' | 'desc'

interface DataTableSort {
  columnId: string
  direction: SortDirection
}

type DataTableAlign = 'left' | 'center' | 'right'

interface DataTableColumn<TRow> {
  id: string
  header: ReactNode
  label?: string
  cell: (row: TRow, rowIndex: number) => ReactNode
  sortable?: boolean
  align?: DataTableAlign
  width?: number
  minWidth?: number
  pinned?: 'left' | 'right'
  hideable?: boolean
  headerClassName?: string
  cellClassName?: string
}

DataTable props

PropTypeDefaultDescription
columnsrequiredDataTableColumn<TRow>[]Column definitions. This array is also the column ORDER — nothing else reorders it.
rowsrequiredTRow[]The rows to render, already sorted, filtered and paginated by you.
getRowIdrequired(row: TRow, index: number) => stringStable identity of a row. Used for the React key and as the selection key.
loadingbooleanfalseRenders loadingRowCount skeleton rows instead of the body and sets aria-busy on the table.
loadingRowCountnumber5How many skeleton rows to draw while loading.
emptyReactNodeReplaces the built-in empty state. Rendered in a full-width cell when rows is empty and loading is false.
sortDataTableSort | nullControlled sort state. null means unsorted. The component never sorts your data.
onSortChange(sort: DataTableSort | null) => voidCalled with the next state of the asc → desc → unsorted cycle. Required before any sortable column renders a button.
selectablebooleanfalseAdds the leading checkbox column, pinned left at 44px.
selectedIdsstring[][]Controlled selection, holding the values returned by getRowId.
onSelectedIdsChange(ids: string[]) => voidCalled with the next selection. Without it both the header and the row checkboxes are inert.
isRowSelectable(row: TRow, index: number) => boolean() => trueLocks the checkbox of individual rows. Locked rows are also excluded from select-all.
visibleColumnIdsstring[]Controlled set of visible column ids. Omit to show everything. Order inside the array is ignored.
onRowClick(row: TRow, index: number) => voidMakes rows activatable. Adds tabIndex=0 and Enter/Space handling on the <tr>.
rowClassName(row: TRow, index: number) => string | undefinedExtra classes per row. Avoid alpha backgrounds here — see the note on pinned columns.
isRowActive(row: TRow, index: number) => boolean() => falseHighlights the row currently open elsewhere, using an inset rule in --app-accent-ink.
stickyHeaderbooleantruePins the header row to the top of the scroll container.
stripedbooleanfalseOpaque zebra striping on even rows. Off by default — worth it on long, dense tables only.
minWidthnumber | stringMinimum table width. Set it to the sum of the column widths to enable horizontal scrolling.
density'comfortable' | 'compact''comfortable'Local override of the app-wide density axis. Only 'compact' writes --row-py and --table-head-h onto the <table>; 'comfortable' writes nothing and inherits <html data-density>.
labelsPartial<DataTableLabels>DEFAULT_DATA_TABLE_LABELSDisplay and screen-reader strings. Vietnamese unless you override them.
aria-labelstringAccessible name of the table. Required when a page carries more than one table.
aria-labelledbystringId of a visible heading to use as the accessible name instead.
classNamestringClasses on the <table> element.
containerClassNamestringClasses on the scroll container — this is where a max-height belongs.

DataTableColumn fields

PropTypeDefaultDescription
idrequiredstringStable column id. Referenced by DataTableSort.columnId and by visibleColumnIds.
headerrequiredReactNodeHeader cell content.
cellrequired(row: TRow, rowIndex: number) => ReactNodeRenders the body cell. The row index is passed too, so a numbering column needs no extra data.
labelstringPlain-text name shown in the column toggle menu. Required whenever header is not a string — otherwise the menu falls back to the id.
sortablebooleanfalseTurns the header into a sort button — but only when the table also has onSortChange.
align'left' | 'center' | 'right''left'Text alignment of header and body cells. On a right-aligned column the sort icon moves to the left of the label.
widthnumberColumn width in px. REQUIRED on pinned columns — the sticky offsets are accumulated from it.
minWidthnumberwidthMinimum column width in px.
pinned'left' | 'right'Freezes the column during horizontal scroll.
hideablebooleantrueSet false to keep the column out of the toggle menu — right for the identifying column.
headerClassNamestringExtra classes on the header cell.
cellClassNamestringExtra classes on every body cell of this column.

DataTableColumnToggle props

PropTypeDefaultDescription
columnsrequiredDataTableColumn<TRow>[]The same array you gave DataTable. Columns with hideable: false are filtered out of the menu.
visibleColumnIdsrequiredstring[]The same state you gave DataTable.visibleColumnIds.
onVisibleColumnIdsChangerequired(visibleColumnIds: string[]) => voidReceives the next set, always rebuilt in the order of columns so the stored value keeps one shape.
labelstringlabels.columnToggleButton text and menu heading. Beats labels.columnToggle when both are given.
labelsPartial<DataTableLabels>DEFAULT_DATA_TABLE_LABELSSame label bag as DataTable; only columnToggle is read here.
classNamestringClasses on the trigger button.

Accessibility

  • The table is a real <table> with <th scope="col"> headers — no ARIA grid pattern is layered on top, so browser table navigation keeps working.
  • Name every table with aria-label or aria-labelledby. It is mandatory once a page has more than one.
  • The sorted column carries aria-sort="ascending" or "descending"; the attribute is absent while unsorted. The sort control is a real <button> inside the header cell, and its icon is aria-hidden.
  • While loading, the table sets aria-busy="true".
  • Selectable rows get aria-selected and data-state="selected". Each checkbox is named from labels.selectRow / deselectRow, and the header one from selectAllRows / deselectAllRows — so translate those four even though nothing on screen shows them.
  • Clickable rows are keyboard operable: tabIndex=0, Enter and Space, and a visible focus outline in foreground (the brand ring colour would only reach ~1.9:1 on card). Space is prevented from scrolling and Enter from submitting an enclosing form.
  • Colour is never the only signal. The sort state changes the icon shape as well as the colour, and the active row is a 2px rule rather than a tint — both readable in greyscale, per WCAG 1.4.1.
  • Non-text contrast (WCAG 1.4.11) is met by the unsorted sort icon at 4.89:1 / 4.86:1, and by the active-row rule at 6.14:1 / 9.61:1 against the card surface.