Table
Structured data tables with sorting, selection, editing, pinned columns, resizing, virtualization, and custom row actions.
Table
Table renders structured data as an accessible native HTML table. It supports client-side sorting, controlled sorting for server-side data, row selection, editable cells, pinned columns and rows, resizable columns, virtualization, loading and empty states, custom cell renderers, and action columns.
The component intentionally does not include column filters, global search, export controls, expandable rows, interactive density buttons, or pagination.
Basic usage
import { Table } from '@mosmox/ui';
const rows = [
{ id: 'alpha', name: 'Alpha', team: 'Design', score: 92 },
{ id: 'beta', name: 'Beta', team: 'Engineering', score: 84 },
];
const columns = [
{ key: 'name', header: 'Name', accessor: 'name', sortable: true },
{ key: 'team', header: 'Team', accessor: 'team', sortable: true },
{ key: 'score', header: 'Score', accessor: 'score', sortable: true, align: 'right' },
];
<Table
ariaLabel="Team scores"
data={rows}
columns={columns}
rowKey="id"
defaultSort={[{ key: 'score', direction: 'desc' }]}
/>Data and columns
Row identity
Use rowKey when rows have a stable identifier. It accepts a property name or a function. Stable keys are important for selection, pinned rows, and virtualization.
<Table data={rows} columns={columns} rowKey={(row) => row.id} />If rowKey is omitted, the component uses row.id when available and otherwise falls back to the row index.
Column values
A column reads its value from accessor first. A function accessor is useful for nested or computed values; a property accessor reads a field; without an accessor, row[column.key] is used.
const columns = [
{ key: 'name', header: 'Project', accessor: 'name' },
{
key: 'ownerName',
header: 'Owner',
accessor: (project) => `${project.owner.firstName} ${project.owner.lastName}`,
},
];Column API
| Property | Type | Description |
|---|---|---|
key | string | Unique column identifier used by sorting, ordering, pinning, and cell keys. |
header | ReactNode | Header content. |
accessor | keyof T | ((row: T) => unknown) | Source value for the cell. |
render | (value, row, state) => ReactNode | Custom renderer for this column. |
editor | (value, row, state) => ReactNode | Editor rendered after an editable cell is double-clicked. |
sortable | boolean | Set to false to disable sorting for this column. |
sort | (a, b, rowA, rowB) => number | Custom client-side comparator. |
width | number | string | Initial CSS width, for example 160 or '12rem'. |
minWidth / maxWidth | number | Resize limits in pixels. |
align | 'left' | 'center' | 'right' | Cell and header alignment. |
pin | 'left' | 'right' | Pins the column during horizontal scrolling. |
resizable | boolean | Set to false to disable the resize handle. |
editable | boolean | Enables the column editor when table editing is enabled. |
className | string | Class applied to body cells. |
headerClassName | string | Class applied to header cells. |
ariaLabel | string | Accessible label for column-specific controls. |
The state passed to renderers and editors contains row, rowKey, column, value, rowIndex, selected, and editing.
Sizes, variants, and density
The two table sizes are:
default: regular typography and spacing;small: smaller typography and tighter cell padding.
<Table data={rows} columns={columns} size="default" />
<Table data={rows} columns={columns} size="small" />Variants are plain, bordered, striped, and card. Density is independent from size and can be compact, comfortable, or spacious.
<Table data={rows} columns={columns} variant="striped" density="compact" />
<Table data={rows} columns={columns} variant="card" density="spacious" />Density is configured through props or ConfigProvider. Table does not render an interactive density switcher.
Sorting
Set sortable: true on a column. Header clicks cycle through ascending, descending, and unsorted states. The default comparator handles numbers, booleans, and text-like values. Use column.sort for domain-specific values.
<Table
data={rows}
columns={columns}
defaultSort={[{ key: 'score', direction: 'desc' }]}
multiSort
/>multiSort defaults to true. Set it to false to keep only one active sort rule.
Server-side sorting
Use controlled sort state and call your API from onSortChange. The callback receives an array of TableSortRule values with a column key and direction.
import { useState } from 'react';
import type { TableSortRule } from '@mosmox/ui';
const [sort, setSort] = useState<TableSortRule[]>([]);
const [rows, setRows] = useState<Project[]>([]);
async function loadProjects(nextSort: TableSortRule[]) {
const query = new URLSearchParams();
nextSort.forEach((rule, index) => {
query.set(`sort[${index}][key]`, rule.key);
query.set(`sort[${index}][direction]`, rule.direction);
});
const response = await fetch(`/api/projects?${query}`);
const result = await response.json();
setRows(result.data);
setSort(nextSort);
}
<Table data={rows} columns={columns} sort={sort} onSortChange={loadProjects} />One rule can be encoded as:
GET /api/projects?sort[0][key]=score&sort[0][direction]=descMultiple rules can be encoded as:
GET /api/projects?sort[0][key]=status&sort[0][direction]=asc&sort[1][key]=score&sort[1][direction]=descThe component receives server-returned rows through data. Keep the API sort keys aligned with column keys.
Row selection
Set selectionMode to single or multiple. The first column renders the shared Checkbox component, including the indeterminate state for select-all.
const [selectedKeys, setSelectedKeys] = useState<(string | number)[]>([]);
<Table
data={rows}
columns={columns}
rowKey="id"
selectionMode="multiple"
selectedRowKeys={selectedKeys}
onSelectionChange={(keys, selectedRows) => {
setSelectedKeys(keys);
console.log('Selected rows:', selectedRows);
}}
/>Use defaultSelectedRowKeys for uncontrolled initial selection. Use isRowDisabled to prevent selection for specific rows. onRowClick is called when a row is clicked.
Row actions
A final column can render any React content. Use the shared Button component for actions. Stop propagation so an action does not also trigger row selection or onRowClick.
import { Eye, DotsThree } from '@mosmox-ui/icons';
import { Button, Table } from '@mosmox/ui';
const columns = [
...baseColumns,
{
key: 'actions',
header: 'Actions',
accessor: () => '',
sortable: false,
resizable: false,
align: 'right',
width: 96,
pin: 'right',
render: (_value, row) => (
<div onClick={(event) => event.stopPropagation()}>
<Button
aria-label={`View ${row.name}`}
iconOnly
iconLeft={<Eye size={14} />}
onClick={() => openProject(row.id)}
size="xs"
variant="text"
/>
<Button
aria-label={`More actions for ${row.name}`}
iconOnly
iconLeft={<DotsThree size={14} />}
onClick={() => openMenu(row.id)}
size="xs"
variant="text"
/>
</div>
),
},
];Add pin: 'right' when actions should stay visible during horizontal scrolling.
Custom rendering
Use a column render function for badges, progress indicators, links, and formatted values. Use renderCell when the same policy should apply to every column.
const columns = [
{
key: 'status',
header: 'Status',
accessor: 'status',
render: (value) => <StatusBadge status={String(value)} />,
},
{
key: 'amount',
header: 'Amount',
accessor: 'amount',
align: 'right',
render: (value) => formatCurrency(Number(value)),
},
];Editing cells
Set editable on the table and editable: true plus an editor on a column. A double-click opens the editor. Persist the new value from onCellEdit.
<Table
data={rows}
columns={[{
key: 'name',
header: 'Name',
accessor: 'name',
editable: true,
editor: (value) => <input defaultValue={String(value ?? '')} />,
}]}
editable
onCellEdit={(row, column, value, rowIndex) => {
saveCell({ id: row.id, column: column.key, value, rowIndex });
}}
/>Pinned columns and rows
Pin columns with column.pin or controlled pinning. defaultPinning provides the initial uncontrolled state.
<Table data={rows} columns={columns} pinning={{ left: ['name'], right: ['actions'] }} />Use pinnedRows to keep known row keys at the top or bottom of the rendered result.
<Table
data={rows}
columns={columns}
rowKey="id"
pinnedRows={{ top: ['summary'], bottom: ['totals'] }}
/>Column order and resizing
Pass columnOrder to control the order explicitly, or defaultColumnOrder for an initial order. The order contains column keys.
<Table
data={rows}
columns={columns}
defaultColumnOrder={['name', 'status', 'score', 'actions']}
columnOrder={order}
onColumnOrderChange={setOrder}
/>Resize handles are enabled by default. Set resizableColumns={false} to disable them globally; set resizable: false on one column; and use minWidth, maxWidth, and onColumnResize to constrain or persist widths.
Loading, empty, and footer states
Use loading while data is fetched. loadingContent and emptyContent customize state content. footer renders below the table and stickyFooter keeps it visible inside a scrollable table.
<Table
data={rows}
columns={columns}
loading={isLoading}
loadingContent={<Spinner />}
emptyContent={<Empty description="No projects found" />}
footer={<Pagination page={page} onChange={setPage} />}
stickyFooter
/>Pagination is intentionally composed outside Table.
Scrollable and virtualized tables
Set maxHeight to make the body scrollable and stickyHeader to keep the header visible. For large datasets, enable virtualize and provide a numeric rowHeight matching the rendered rows.
<Table data={largeDataset} columns={columns} maxHeight={520} stickyHeader virtualize rowHeight={48} />Avoid variable-height rows when virtualization is enabled.
ConfigProvider defaults
Table reads size, variant, density, and stickyHeader from ConfigProvider. Instance props take precedence.
<ConfigProvider
components={{
table: {
size: 'small',
variant: 'striped',
density: 'compact',
stickyHeader: true,
},
}}
>
<Table data={rows} columns={columns} />
</ConfigProvider>Props reference
General
| Prop | Type | Default | Description |
|---|---|---|---|
data | T[] | required | Rows to render. |
columns | TableColumn<T>[] | required | Column definitions. |
rowKey | keyof T | ((row, index) => string | number) | id or index | Stable row identity. |
caption | ReactNode | — | Optional visible caption. |
captionSide | 'top' | 'bottom' | 'top' | Caption placement. |
ariaLabel | string | — | Accessible name for the table. |
size | 'default' | 'small' | 'default' | Table typography and spacing. |
variant | 'plain' | 'bordered' | 'striped' | 'card' | 'plain' | Visual variant. |
density | 'compact' | 'comfortable' | 'spacious' | 'comfortable' | Cell padding density. |
stickyHeader | boolean | false | Keeps header visible in the scroll area. |
maxHeight | number | string | — | Scroll area maximum height. |
bordered | boolean | false | Adds a scroll-area border. |
hoverable | boolean | true | Applies row hover styling. |
Sorting
| Prop | Type | Default | Description |
|---|---|---|---|
sortable | boolean | true | Enables sortable headers globally. |
multiSort | boolean | true | Keeps multiple active rules. |
defaultSort | TableSortRule[] | [] | Initial uncontrolled sort. |
sort | TableSortRule[] | — | Controlled sort state. |
onSortChange | (sort) => void | — | Receives next sort rules. |
Selection and interaction
| Prop | Type | Default | Description |
|---|---|---|---|
selectionMode | 'none' | 'single' | 'multiple' | 'none' | Enables row selection. |
selectedRowKeys | TableRowKey[] | — | Controlled selection. |
defaultSelectedRowKeys | TableRowKey[] | [] | Initial uncontrolled selection. |
onSelectionChange | (keys, rows) => void | — | Receives selected keys and rows. |
onRowClick | (row, rowIndex) => void | — | Called when a row is clicked. |
isRowDisabled | (row, rowIndex) => boolean | — | Disables selection for a row. |
rowClassName | (row, rowIndex) => string | undefined | — | Adds a row class. |
Layout, rendering, and state
| Prop | Type | Default | Description |
|---|---|---|---|
editable | boolean | false | Enables editors on double-click. |
onCellEdit | (row, column, value, rowIndex) => void | — | Receives edited values. |
columnOrder | string[] | — | Controlled column key order. |
defaultColumnOrder | string[] | columns order | Initial column order. |
onColumnOrderChange | (order) => void | — | Callback for external order controls. |
resizableColumns | boolean | true | Enables resize handles. |
onColumnResize | (column, width) => void | — | Called while resizing. |
pinning | { left?: string[]; right?: string[] } | — | Controlled pinned columns. |
defaultPinning | same as pinning | {} | Initial pinned columns. |
onPinningChange | (pinning) => void | — | Callback for external pinning controls. |
pinnedRows | { top?: TableRowKey[]; bottom?: TableRowKey[] } | {} | Pins known row keys. |
rowHeight | number | 48 | Estimated row height. |
virtualize | boolean | false | Renders visible body rows only. |
renderCell | (value, row, column, state) => ReactNode | — | Table-wide renderer. |
showToolbar | boolean | true | Allows custom toolbar rendering. |
toolbar | ReactNode | — | Custom toolbar content. |
footer | ReactNode | — | Content below the table. |
stickyFooter | boolean | false | Keeps footer visible. |
Loading and content
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | false | Shows loading content. |
loadingContent | ReactNode | 'Loading…' | Loading state. |
emptyContent | ReactNode | 'No data to display.' | Empty state. |
Accessibility
- The component renders a native
<table>with an accessible name fromariaLabel. - Sortable headers expose
aria-sort. - Selection uses the shared
Checkboxwith accessible labels. - Icon-only action buttons need an
aria-label. - Keep headers meaningful and label custom editors.
Removed functionality
The current API intentionally does not provide column filters, a global search field, CSV/export controls, expandable detail rows, interactive density buttons, or built-in pagination. Compose those features around Table when required.