Table
Dumb styled primitives for building data tables. Wire any feature — sorting, selection, pinning, expanding — with @tanstack/react-table or any other headless library.
Basic
| Property | City | ADR |
|---|---|---|
| Grand Hotel | Berlin | 180 |
| Hotel Riverside | Prague | 120 |
| The Lighthouse Inn | Amsterdam | 215 |
| City Suites | Vienna | 145 |
| Palace Court | Paris | 320 |
Show code Hide code
import { Table } from '@mylighthouse/prism-react';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
type Property = { adr: number; city: string; name: string };
const columns: ColumnDef<Property>[] = [
{ accessorKey: 'name', header: 'Property' },
{ accessorKey: 'city', header: 'City' },
{ accessorKey: 'adr', header: 'ADR' },
];
const data: Property[] = [
{ name: 'Grand Hotel', city: 'Berlin', adr: 180 },
{ name: 'Hotel Riverside', city: 'Prague', adr: 120 },
{ name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215 },
{ name: 'City Suites', city: 'Vienna', adr: 145 },
{ name: 'Palace Court', city: 'Paris', adr: 320 },
];
export default function TableBasicExample(): React.JSX.Element {
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<Table aria-label="Hotels ADR">
<Table.Header>
{table.getHeaderGroups().map((group) => (
<Table.Row key={group.id}>
{group.headers.map((header) => (
<Table.Header.Cell colSpan={header.colSpan} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Header.Cell>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
);
} Fixed column
The Property column is pinned left. Scroll horizontally to see it stay in place. Consumer owns a local getCommonPinningStyles helper and spreads it into style on header cells and data cells. <Table> sets border-collapse: separate; border-spacing: 0 on the inner <table> — required for sticky to work.
| Property | City | ADR | Occupancy |
|---|---|---|---|
| Grand Hotel | Berlin | 180 | 78% |
| Hotel Riverside | Prague | 120 | 65% |
| The Lighthouse Inn | Amsterdam | 215 | 82% |
| City Suites | Vienna | 145 | 71% |
| Palace Court | Paris | 320 | 91% |
Show code Hide code
import { Table } from '@mylighthouse/prism-react';
import {
type Column,
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import type { CSSProperties } from 'react';
type Property = { adr: number; city: string; name: string; occupancy: string };
const getCommonPinningStyles = (column: Column<Property>): CSSProperties => {
const isPinned = column.getIsPinned();
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left');
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right');
return {
borderRight: isLastLeftPinnedColumn
? '1px solid var(--prism-color-border-neutral-default)'
: undefined,
borderLeft: isFirstRightPinnedColumn
? '1px solid var(--prism-color-border-neutral-default)'
: undefined,
color: isPinned ? 'var(--prism-color-text-neutral-emphasis)' : undefined,
fontWeight: isPinned
? 'var(--prism-font-weight-text-300-semi-bold)'
: undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
opacity: isPinned ? 0.95 : 1,
position: isPinned ? 'sticky' : 'relative',
width: column.getSize(),
zIndex: isPinned ? 1 : 0,
backgroundColor: 'var(--prism-color-elevation-surface)',
};
};
const columns: ColumnDef<Property>[] = [
{ accessorKey: 'name', header: 'Property', size: 200 },
{ accessorKey: 'city', header: 'City', size: 200 },
{ accessorKey: 'adr', header: 'ADR', size: 200 },
{ accessorKey: 'occupancy', header: 'Occupancy', size: 200 },
];
const data: Property[] = [
{ name: 'Grand Hotel', city: 'Berlin', adr: 180, occupancy: '78%' },
{ name: 'Hotel Riverside', city: 'Prague', adr: 120, occupancy: '65%' },
{ name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215, occupancy: '82%' },
{ name: 'City Suites', city: 'Vienna', adr: 145, occupancy: '71%' },
{ name: 'Palace Court', city: 'Paris', adr: 320, occupancy: '91%' },
];
export default function TableFixedColumnExample(): React.JSX.Element {
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
initialState: { columnPinning: { left: ['name'] } },
});
return (
<div style={{ width: 450, overflowX: 'auto' }}>
<Table
aria-label="Occupancy and ADR by property"
style={{ tableLayout: 'fixed' }}
>
<Table.Header>
{table.getHeaderGroups().map((group) => (
<Table.Row key={group.id}>
{group.headers.map((header) => (
<Table.Header.Cell
colSpan={header.colSpan}
key={header.id}
style={getCommonPinningStyles(header.column)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Header.Cell>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell
key={cell.id}
style={getCommonPinningStyles(cell.column)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
);
} Skeleton
Render <Table.SkeletonBar> cells while data loads. Consumer controls row count, column shape, and widthPct per cell.
| Column A | Column B | Column C |
|---|---|---|
Show code Hide code
import { Table } from '@mylighthouse/prism-react';
export default function TableSkeletonExample(): React.JSX.Element {
return (
<Table>
<Table.Header>
<Table.Row>
<Table.Header.Cell>Column A</Table.Header.Cell>
<Table.Header.Cell>Column B</Table.Header.Cell>
<Table.Header.Cell>Column C</Table.Header.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell colSpan={3}>
<Table.SkeletonBar width="55%" />
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell colSpan={3}>
<Table.SkeletonBar width="35%" />
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell colSpan={3} />
</Table.Row>
<Table.Row>
<Table.Cell colSpan={3} />
</Table.Row>
</Table.Body>
</Table>
);
} Collapsible rows
Single or multi-level. row.depth drives the left-padding indent; TanStack recurses getSubRows automatically.
| Property | ADR |
|---|---|
Northern Europe | 168 |
Western Europe | 244 |
Show code Hide code
import { Button, Table } from '@mylighthouse/prism-react';
import {
type ColumnDef,
type ExpandedState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
type Row = { adr: number; children?: Row[]; name: string };
const columns: ColumnDef<Row>[] = [
{
accessorKey: 'name',
header: 'Property',
cell: ({ row, getValue }) => (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--prism-spacing-200)',
paddingLeft: `calc(${row.depth} * var(--prism-spacing-400))`,
}}
>
{row.getCanExpand() ? (
<Button
aria-controls={row.subRows.map((subRow) => subRow.id).join(' ')}
aria-expanded={row.getIsExpanded()}
aria-label={
row.getIsExpanded()
? `Collapse ${row.original.name}`
: `Expand ${row.original.name}`
}
buttonType="ghost"
icon={row.getIsExpanded() ? 'chevron-down' : 'chevron-right'}
onClick={row.getToggleExpandedHandler()}
size="small"
/>
) : (
<span style={{ width: 20 }} />
)}
{getValue<string>()}
</div>
),
},
{ accessorKey: 'adr', header: 'ADR' },
];
const data: Row[] = [
{
name: 'Northern Europe',
adr: 168,
children: [
{ name: 'Grand Hotel Berlin', adr: 180 },
{
name: 'Hotel Riverside Prague',
adr: 120,
children: [
{ name: 'Superior rooms', adr: 135 },
{ name: 'Standard rooms', adr: 105 },
],
},
],
},
{
name: 'Western Europe',
adr: 244,
children: [
{ name: 'The Lighthouse Inn Amsterdam', adr: 215 },
{ name: 'Palace Court Paris', adr: 320 },
],
},
];
export default function TableCollapsibleExample(): React.JSX.Element {
const [expanded, setExpanded] = useState<ExpandedState>({});
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: { expanded },
onExpandedChange: setExpanded,
getSubRows: (row) => row.children,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
});
return (
<Table aria-label="ADR by region and property">
<Table.Header>
{table.getHeaderGroups().map((group) => (
<Table.Row key={group.id}>
{group.headers.map((header) => (
<Table.Header.Cell colSpan={header.colSpan} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Header.Cell>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => (
<Table.Row id={row.id} key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
);
} Row selection
<Table.Row isSelected> applies the selection background. The select-all checkbox in the header uses indeterminate when only some rows are selected.
| Property | City | ADR | |
|---|---|---|---|
| Grand Hotel | Berlin | 180 | |
| Hotel Riverside | Prague | 120 | |
| The Lighthouse Inn | Amsterdam | 215 | |
| City Suites | Vienna | 145 | |
| Palace Court | Paris | 320 |
Show code Hide code
import { Checkbox, Table } from '@mylighthouse/prism-react';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
type RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
type Property = { adr: number; city: string; id: string; name: string };
const data: Property[] = [
{ id: 'p1', name: 'Grand Hotel', city: 'Berlin', adr: 180 },
{ id: 'p2', name: 'Hotel Riverside', city: 'Prague', adr: 120 },
{ id: 'p3', name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215 },
{ id: 'p4', name: 'City Suites', city: 'Vienna', adr: 145 },
{ id: 'p5', name: 'Palace Court', city: 'Paris', adr: 320 },
];
const columns: ColumnDef<Property>[] = [
{
id: 'select',
size: 40,
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label={`Select ${row.original.name}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{ accessorKey: 'name', header: 'Property' },
{ accessorKey: 'city', header: 'City' },
{ accessorKey: 'adr', header: 'ADR' },
];
export default function TableSelectionExample(): React.JSX.Element {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
});
return (
<Table aria-label="ADR by property selection">
<Table.Header>
{table.getHeaderGroups().map((group) => (
<Table.Row key={group.id}>
{group.headers.map((header) => (
<Table.Header.Cell
colSpan={header.colSpan}
key={header.id}
{...(header.id === 'select'
? { 'aria-label': 'Select all' }
: {})}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Header.Cell>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => (
<Table.Row isSelected={row.getIsSelected()} key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
);
} Sorting
Consumer renders a sort button inside each header cell. getSortedRowModel from TanStack handles the sort logic.
| ADR | ||
|---|---|---|
| Grand Hotel | Berlin | 180 |
| Hotel Riverside | Prague | 120 |
| The Lighthouse Inn | Amsterdam | 215 |
| City Suites | Vienna | 145 |
| Palace Court | Paris | 320 |
Show code Hide code
import type { IconName } from '@mylighthouse/prism-foundation';
import { Button, Table } from '@mylighthouse/prism-react';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type Header,
type SortingState,
useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
type Property = { adr: number; city: string; name: string };
function sortIconName(state: false | 'asc' | 'desc'): IconName {
if (state === 'asc') return 'sort-asc';
if (state === 'desc') return 'sort-desc';
return 'sort';
}
function SortableHeader({
header,
}: {
header: Header<Property, unknown>;
}): React.JSX.Element {
if (!header.column.getCanSort()) {
return (
<>{flexRender(header.column.columnDef.header, header.getContext())}</>
);
}
const sort = header.column.getIsSorted();
return (
<Button
aria-label={
sort === 'asc'
? 'Sort descending'
: sort === 'desc'
? 'Clear sort'
: 'Sort ascending'
}
buttonType="ghost"
iconAfter={sortIconName(sort)}
onClick={header.column.getToggleSortingHandler()}
size="small"
>
{flexRender(header.column.columnDef.header, header.getContext())}
</Button>
);
}
const columns: ColumnDef<Property>[] = [
{ accessorKey: 'name', header: 'Property' },
{ accessorKey: 'city', header: 'City' },
{ accessorKey: 'adr', header: 'ADR', enableSorting: false },
];
const data: Property[] = [
{ name: 'Grand Hotel', city: 'Berlin', adr: 180 },
{ name: 'Hotel Riverside', city: 'Prague', adr: 120 },
{ name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215 },
{ name: 'City Suites', city: 'Vienna', adr: 145 },
{ name: 'Palace Court', city: 'Paris', adr: 320 },
];
export default function TableSortingExample(): React.JSX.Element {
const [sorting, setSorting] = useState<SortingState>([]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<Table aria-label="ADR by property">
<Table.Header>
{table.getHeaderGroups().map((group) => (
<Table.Row key={group.id}>
{group.headers.map((header) => (
<Table.Header.Cell
aria-sort={
!header.column.getCanSort()
? undefined
: header.column.getIsSorted() === 'asc'
? 'ascending'
: header.column.getIsSorted() === 'desc'
? 'descending'
: 'none'
}
colSpan={header.colSpan}
key={header.id}
>
<SortableHeader header={header} />
</Table.Header.Cell>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
);
} Header groups
Nest columns in the column def to create grouped headers. The standard header loop renders one <tr> per group, honouring header.colSpan and header.isPlaceholder.
| Rate metrics | Volume metrics | |||
|---|---|---|---|---|
| Property | ADR | RevPAR | Revenue | Occupancy |
| Grand Hotel | 180 | 140 | 48000 | 78% |
| Hotel Riverside | 120 | 78 | 31000 | 65% |
| The Lighthouse Inn | 215 | 176 | 64000 | 82% |
| City Suites | 145 | 103 | 37000 | 71% |
Show code Hide code
import { Table } from '@mylighthouse/prism-react';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
type Property = {
adr: number;
name: string;
occupancy: string;
revenue: number;
revpar: number;
};
const columns: ColumnDef<Property>[] = [
{ accessorKey: 'name', header: 'Property' },
{
header: 'Rate metrics',
columns: [
{ accessorKey: 'adr', header: 'ADR' },
{ accessorKey: 'revpar', header: 'RevPAR' },
],
},
{
header: 'Volume metrics',
columns: [
{ accessorKey: 'revenue', header: 'Revenue' },
{ accessorKey: 'occupancy', header: 'Occupancy' },
],
},
];
const data: Property[] = [
{
name: 'Grand Hotel',
adr: 180,
revpar: 140,
revenue: 48000,
occupancy: '78%',
},
{
name: 'Hotel Riverside',
adr: 120,
revpar: 78,
revenue: 31000,
occupancy: '65%',
},
{
name: 'The Lighthouse Inn',
adr: 215,
revpar: 176,
revenue: 64000,
occupancy: '82%',
},
{
name: 'City Suites',
adr: 145,
revpar: 103,
revenue: 37000,
occupancy: '71%',
},
];
export default function TableHeaderGroupsExample(): React.JSX.Element {
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<Table aria-label="Rate and volume metrics by property">
<Table.Header>
{table.getHeaderGroups().map((group) => (
<Table.Row key={group.id}>
{group.headers.map((header) => (
<Table.Header.Cell
colSpan={header.colSpan}
key={header.id}
scope={header.colSpan > 1 ? 'colgroup' : 'col'}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Header.Cell>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
);
} Notes
Tanstack Table library does not work with the react’s compiler memoization. For this reason, when implemented, the "use no memo" directive should be added to the first line of a component or a hook, and the // eslint-disable-next-line react-hooks/incompatible-library comment should be added before calling the useReactTable hook.
API Reference
Table
| Name | Default | Type | Description |
|---|---|---|---|
bordered | -- | boolean | -- |
Table.Header
No component-specific props
Table.Header.Cell
No component-specific props
Table.Body
No component-specific props
Table.Foot
No component-specific props
Table.Row
| Name | Default | Type | Description |
|---|---|---|---|
isActive | -- | boolean | -- |
isSelected | -- | boolean | -- |
Table.Cell
No component-specific props
Table.SkeletonBar
No component-specific props