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. For a working list with many fields — filtered and sorted by several conditions at once, or corrected in place, like tasks, customers or orders — the sibling is Data Grid.
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.
| Invoice | Client | Owner | Issued | Amount | Status |
|---|---|---|---|---|---|
| INV-2041 | Aurora Logistics | Mai Tran | Aug 2, 2026 | $4,820 | Paid |
| INV-2042 | Brightline Media | Duc Pham | Aug 4, 2026 | $1,290 | Pending |
| INV-2043 | Cormorant Foods | Linh Vo | Jul 28, 2026 | $7,640 | Overdue |
| INV-2044 | Delta Shipworks | Mai Tran | Aug 9, 2026 | $980 | Pending |
| INV-2045 | Everest Analytics | Huy Nguyen | Aug 11, 2026 | $15,400 | Paid |
| INV-2046 | Foundry Labs | Linh Vo | Aug 14, 2026 | $2,310 | Pending |
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' }
| Invoice | Owner | Status | |||
|---|---|---|---|---|---|
| INV-2041 | Aurora Logistics | Mai Tran | Aug 2, 2026 | $4,820 | Paid |
| INV-2042 | Brightline Media | Duc Pham | Aug 4, 2026 | $1,290 | Pending |
| INV-2043 | Cormorant Foods | Linh Vo | Jul 28, 2026 | $7,640 | Overdue |
| INV-2044 | Delta Shipworks | Mai Tran | Aug 9, 2026 | $980 | Pending |
| INV-2045 | Everest Analytics | Huy Nguyen | Aug 11, 2026 | $15,400 | Paid |
| INV-2046 | Foundry Labs | Linh Vo | Aug 14, 2026 | $2,310 | Pending |
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.
| Invoice | Client | Owner | Issued | Amount | Status | |
|---|---|---|---|---|---|---|
| INV-2041 | Aurora Logistics | Mai Tran | Aug 2, 2026 | $4,820 | Paid | |
| INV-2042 | Brightline Media | Duc Pham | Aug 4, 2026 | $1,290 | Pending | |
| INV-2043 | Cormorant Foods | Linh Vo | Jul 28, 2026 | $7,640 | Overdue | |
| INV-2044 | Delta Shipworks | Mai Tran | Aug 9, 2026 | $980 | Pending | |
| INV-2045 | Everest Analytics | Huy Nguyen | Aug 11, 2026 | $15,400 | Paid | |
| INV-2046 | Foundry Labs | Linh Vo | Aug 14, 2026 | $2,310 | Pending |
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']| Invoice | Client | Owner | Issued | Amount | Status |
|---|---|---|---|---|---|
| INV-2041 | Aurora Logistics | Mai Tran | Aug 2, 2026 | $4,820 | Paid |
| INV-2042 | Brightline Media | Duc Pham | Aug 4, 2026 | $1,290 | Pending |
| INV-2043 | Cormorant Foods | Linh Vo | Jul 28, 2026 | $7,640 | Overdue |
| INV-2044 | Delta Shipworks | Mai Tran | Aug 9, 2026 | $980 | Pending |
| INV-2045 | Everest Analytics | Huy Nguyen | Aug 11, 2026 | $15,400 | Paid |
| INV-2046 | Foundry Labs | Linh Vo | Aug 14, 2026 | $2,310 | Pending |
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. TheIdssuffix exists to say exactly that — the 0.1.0 namevisibleColumnsread 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.
| Invoice | Client | Owner | Contact | Issued | Terms | Status | Amount |
|---|---|---|---|---|---|---|---|
| INV-2041 | Aurora Logistics | Mai Tran | [email protected] | Aug 2, 2026 | Net 30 | Paid | $4,820 |
| INV-2042 | Brightline Media | Duc Pham | [email protected] | Aug 4, 2026 | Net 30 | Pending | $1,290 |
| INV-2043 | Cormorant Foods | Linh Vo | [email protected] | Jul 28, 2026 | Net 30 | Overdue | $7,640 |
| INV-2044 | Delta Shipworks | Mai Tran | [email protected] | Aug 9, 2026 | Net 30 | Pending | $980 |
| INV-2045 | Everest Analytics | Huy Nguyen | [email protected] | Aug 11, 2026 | Net 30 | Paid | $15,400 |
| INV-2046 | Foundry Labs | Linh Vo | [email protected] | Aug 14, 2026 | Net 30 | Pending | $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.
| Invoice | Client | Issued | Amount | Status |
|---|---|---|---|---|
| INV-2041 | Aurora Logistics | Aug 2, 2026 | $4,820 | Paid |
| INV-2042 | Brightline Media | Aug 4, 2026 | $1,290 | Pending |
| INV-2043 | Cormorant Foods | Jul 28, 2026 | $7,640 | Overdue |
| INV-2044 | Delta Shipworks | Aug 9, 2026 | $980 | Pending |
| INV-2045 | Everest Analytics | Aug 11, 2026 | $15,400 | Paid |
| INV-2046 | Foundry Labs | Aug 14, 2026 | $2,310 | Pending |
- The row keeps its implicit
role="row"; norole="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
outlineand a negative offset, notring:box-shadowis not painted on a<tr>in a border-collapse table, and a positive offset would be clipped by the scroll container. Its colour isforeground, 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. isRowActivetints each cell with--accent, the same fill a selected row gets and the oneDataGriduses, so every table in the package marks a row the same way. The tint goes on the cells, not the row: an opaque fill on each<td>keeps pinned cells solid while you scroll and wins over the even-row stripe, where a class on the<tr>would lose. No border either, 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.
| Invoice | Client | Owner | Issued | Amount | Status |
|---|---|---|---|---|---|
| INV-2041 | Aurora Logistics | Mai Tran | Aug 2, 2026 | $4,820 | Paid |
| INV-2042 | Brightline Media | Duc Pham | Aug 4, 2026 | $1,290 | Pending |
| INV-2043 | Cormorant Foods | Linh Vo | Jul 28, 2026 | $7,640 | Overdue |
| INV-2044 | Delta Shipworks | Mai Tran | Aug 9, 2026 | $980 | Pending |
| INV-2045 | Everest Analytics | Huy Nguyen | Aug 11, 2026 | $15,400 | Paid |
| INV-2046 | Foundry Labs | Linh Vo | Aug 14, 2026 | $2,310 | Pending |
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.
| Invoice | Client | Owner | Issued | Amount | Status |
|---|---|---|---|---|---|
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 — including the four aria-label strings that only a screen reader ever hears — comes from the dataTable namespace of the nearest ComitorLocaleProvider: English without one, Vietnamese under locale="vi". Language is set once, at the root; labels is for wording on top of it — no fork, no wrapper. Below, one empty table three ways: untouched, under the Vietnamese provider, and under it with a single labels key, which wins.
No provider
| Invoice | Client |
|---|---|
No data yet This list is empty. | |
locale="vi"
| Hoá đơn | Khách hàng |
|---|---|
Chưa có dữ liệu Danh sách hiện đang trống. | |
locale="vi" + one labels key
| Hoá đơn | Khách hàng |
|---|---|
Chưa có hoá đơn Danh sách hiện đang trống. | |
Three rules govern the bag, and they are the same across TablePaginationLabels, SearchFilterBarLabels and ShellLabels:
- The prop is
Partial<>. The component merges it over the provider's table, so overriding one key keeps the rest — in the provider's language, as the third table shows. - Keys that interpolate a number or a unit are functions, not strings with placeholders.
DataTableLabelshappens to have none — all seven keys are plain strings — but its siblingTablePaginationLabelsdoes, and the reason is on that page. - A standalone prop beats
labels.DataTableColumnTogglekeeps alabelprop because that string varies by content ("Fields", "Columns") whilelabels.columnTogglevaries by language.
import {
ComitorLocaleProvider,
DataTable,
DataTableColumnToggle,
DEFAULT_DATA_TABLE_LABELS,
comitorMessagesVi,
} from '@comitor/ui'
// 1. The prop is Partial<DataTableLabels>, merged over the provider's dataTable table,
// so one key is a legal override and the other six keep the provider's language.
<DataTable {...tableProps} labels={{ emptyTitle: 'No invoices yet' }} />
// 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. Rewording a key for EVERY table is the provider's job, not each call site's. Keep the
// object at module level — a new object on every render re-renders every consumer.
const MESSAGES = { dataTable: { emptyTitle: 'Nothing here yet' } }
<ComitorLocaleProvider messages={MESSAGES}>{children}</ComitorLocaleProvider>
// Both built-in tables are plain data in modules with NO 'use client', so a Server Component
// can read them — spread them, diff them, build a list from them.
DEFAULT_DATA_TABLE_LABELS // English — the component itself reads the provider, not this
comitorMessagesVi.dataTable // Vietnamese
const withOneChange = { ...comitorMessagesVi.dataTable, 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 same goes for comitorMessagesVi. The full set of keys, with the English defaults and the Vietnamese table's wording:
| Prop | Type | Default | Description |
|---|---|---|---|
selectAllRows | string | 'Select all visible rows' | aria-label of the header checkbox while not everything is selected. Vietnamese: “Chọn tất cả dòng đang hiển thị” |
deselectAllRows | string | 'Deselect all' | aria-label of the header checkbox once every selectable row is selected. Vietnamese: “Bỏ chọn tất cả” |
selectRow | string | 'Select row' | aria-label of an unselected row checkbox. Vietnamese: “Chọn dòng” |
deselectRow | string | 'Deselect row' | aria-label of a selected row checkbox. Vietnamese: “Bỏ chọn dòng” |
emptyTitle | string | 'No data yet' | Title of the built-in empty state. Ignored when you pass empty. Vietnamese: “Chưa có dữ liệu” |
emptyDescription | string | 'This list is empty.' | Description of the built-in empty state. Vietnamese: “Danh sách hiện đang trống.” |
columnToggle | string | 'Columns' | Button text and menu heading of DataTableColumnToggle. Vietnamese: “Cột hiển thị” |
Types
All of these are exported from @comitor/ui alongside the components.
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
| Prop | Type | Default | Description |
|---|---|---|---|
columnsrequired | DataTableColumn<TRow>[] | — | Column definitions. This array is also the column ORDER — nothing else reorders it. |
rowsrequired | TRow[] | — | The rows to render, already sorted, filtered and paginated by you. |
getRowIdrequired | (row: TRow, index: number) => string | — | Stable identity of a row. Used for the React key and as the selection key. |
loading | boolean | false | Renders loadingRowCount skeleton rows instead of the body and sets aria-busy on the table. |
loadingRowCount | number | 5 | How many skeleton rows to draw while loading. |
empty | ReactNode | — | Replaces the built-in empty state. Rendered in a full-width cell when rows is empty and loading is false. |
sort | DataTableSort | null | — | Controlled sort state. null means unsorted. The component never sorts your data. |
onSortChange | (sort: DataTableSort | null) => void | — | Called with the next state of the asc → desc → unsorted cycle. Required before any sortable column renders a button. |
selectable | boolean | false | Adds the leading checkbox column, pinned left at 44px. |
selectedIds | string[] | [] | Controlled selection, holding the values returned by getRowId. |
onSelectedIdsChange | (ids: string[]) => void | — | Called with the next selection. Without it both the header and the row checkboxes are inert. |
isRowSelectable | (row: TRow, index: number) => boolean | () => true | Locks the checkbox of individual rows. Locked rows are also excluded from select-all. |
visibleColumnIds | string[] | — | Controlled set of visible column ids. Omit to show everything. Order inside the array is ignored. |
onRowClick | (row: TRow, index: number) => void | — | Makes rows activatable. Adds tabIndex=0 and Enter/Space handling on the <tr>. |
rowClassName | (row: TRow, index: number) => string | undefined | — | Extra classes per row. Avoid alpha backgrounds here — see the note on pinned columns. |
isRowActive | (row: TRow, index: number) => boolean | () => false | Highlights the row currently open elsewhere with the --accent tint on each cell — the same fill as a selected row and as DataGrid. |
stickyHeader | boolean | true | Pins the header row to the top of the scroll container. |
striped | boolean | false | Opaque zebra striping on even rows. Off by default — worth it on long, dense tables only. |
minWidth | number | string | — | Minimum 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>. |
labels | Partial<DataTableLabels> | from provider | Display and screen-reader strings, merged over the dataTable table of ComitorLocaleProvider — English without one. |
aria-label | string | — | Accessible name of the table. Required when a page carries more than one table. |
aria-labelledby | string | — | Id of a visible heading to use as the accessible name instead. |
className | string | — | Classes on the <table> element. |
containerClassName | string | — | Classes on the scroll container — this is where a max-height belongs. |
DataTableColumn fields
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Stable column id. Referenced by DataTableSort.columnId and by visibleColumnIds. |
headerrequired | ReactNode | — | Header cell content. |
cellrequired | (row: TRow, rowIndex: number) => ReactNode | — | Renders the body cell. The row index is passed too, so a numbering column needs no extra data. |
label | string | — | Plain-text name shown in the column toggle menu. Required whenever header is not a string — otherwise the menu falls back to the id. |
sortable | boolean | false | Turns 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. |
width | number | — | Column width in px. REQUIRED on pinned columns — the sticky offsets are accumulated from it. |
minWidth | number | width | Minimum column width in px. |
pinned | 'left' | 'right' | — | Freezes the column during horizontal scroll. |
hideable | boolean | true | Set false to keep the column out of the toggle menu — right for the identifying column. |
headerClassName | string | — | Extra classes on the header cell. |
cellClassName | string | — | Extra classes on every body cell of this column. |
DataTableColumnToggle props
| Prop | Type | Default | Description |
|---|---|---|---|
columnsrequired | DataTableColumn<TRow>[] | — | The same array you gave DataTable. Columns with hideable: false are filtered out of the menu. |
visibleColumnIdsrequired | string[] | — | The same state you gave DataTable.visibleColumnIds. |
onVisibleColumnIdsChangerequired | (visibleColumnIds: string[]) => void | — | Receives the next set, always rebuilt in the order of columns so the stored value keeps one shape. |
label | string | labels.columnToggle | Button text and menu heading. Beats labels.columnToggle when both are given. |
labels | Partial<DataTableLabels> | from provider | Same label bag as DataTable; only columnToggle is read here. |
className | string | — | Classes 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-labeloraria-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 isaria-hidden. - While
loading, the table setsaria-busy="true". - Selectable rows get
aria-selectedanddata-state="selected". Each checkbox is named fromlabels.selectRow/deselectRow, and the header one fromselectAllRows/deselectAllRows. Nothing on screen shows those four, so they are the ones to check: a Vietnamese app that forgets the provider looks right and still announces them in English. - Clickable rows are keyboard operable:
tabIndex=0, Enter and Space, and a visible focus outline inforeground(the brand ring colour would only reach ~1.9:1 on card). Space is prevented from scrolling and Enter from submitting an enclosing form. - The sort state changes the icon shape as well as the colour, so it reads in greyscale (WCAG 1.4.1). Selected rows carry a ticked checkbox as well as the tint.
- Non-text contrast (WCAG 1.4.11) is met by the unsorted sort icon at 4.89:1 / 4.86:1 against the card surface.