Centrim Design System — a token-first, accessible React component system.
Tables & data display
The V2 data table and its supporting parts — toolbar, sorting, filtering, selection, bulk actions, row actions, pagination, responsive strategies and the full state lifecycle — composed entirely from components standardised in earlier steps.
Overview
What is this?
A single generic DataTable plus the primitives it is built from. Every control inside a table — Checkbox, Button, IconButton, Select, SearchInput, DatePicker, Badge, Avatar, Pagination, Tooltip, DropdownMenu and the state components — is the existing V2 component, unmodified.
When to use it
- Records compared across the same fields, at any scale.
- Lists that need sorting, filtering, selection or bulk actions.
- Any table that can load slowly, come back empty, or fail.
How to use it
- Describe columns declaratively: header, cell, accessor, sortable, filterOptions, hideBelow.
- Give every table a caption, an itemLabel for the results line, and states for empty / no results / error.
- Prioritise columns with hideBelow and keep the hidden values in mobileSummary.
What to avoid
- A table-only checkbox, pagination, badge, input or error component.
- Sorting or status communicated by colour or icon position alone.
- Hiding columns on mobile without a stacked alternative.
Specification
| Part | Treatment | Token source |
|---|---|---|
| Container | 1px subtle border, radius-md, horizontal scroll | --color-border-subtle / --ds-radius-md |
| Header cell | overline (11px uppercase), tertiary text, tinted header band | --ds-text-overline / --color-background-subtle |
| Row divider | 1px subtle horizontal rule — no vertical grid lines | --color-border-subtle |
| Row height | compact 8px, default 12px, comfortable 16px vertical padding | --ds-space-2 / 3 / 4 |
| Hover | surface-hover fill | --color-surface-hover |
| Selected | primary-subtle fill, persists over hover | --color-primary-subtle |
| Disabled row | pointer-events off, disabled text, still readable | --color-text-disabled |
| Sort glyph | ArrowUp / ArrowDown / ArrowUpDown at icon-xs | --size-icon-xs |
| Focus | shared focus ring on every control and the scroll region | ds-focus-ring |
| Footer | results line + page size + Pagination, 12px gap | --ds-space-3 |
Why one table, not a table per screen
Sorting, selection and pagination are where accessibility regressions hide: a missing aria-sort, a select-all that lies about partial state, a spinner that removes the header. Modelling columns as data means those behaviours are written once and every screen inherits the fixed version.
Borders are deliberately sparse — a single outer border and horizontal row rules. Vertical grid lines add weight without adding structure, and alignment plus consistent row height already carry the scan.
Components
Data table
One generic table that composes the existing V2 parts: toolbar search and filters, accessible sorting, Checkbox selection with a bulk action bar, Badge / Avatar / link cells, row actions, Pagination and the shared state components. Tables never introduce their own controls.
import { DataTable, type DataTableColumn } from "@/design-system";
When to use
- Comparing many records across the same set of fields.
- Lists that need sorting, filtering, selection or bulk actions.
When not to use
- One record's details — use DescriptionList or KeyValue.
- Two or three fields per item on mobile-first screens — use DataRow or List.
Resident management — the complete table
Search, column filters, sorting, select-all, bulk actions, status badges, avatar cells, truncated notes, row actions and pagination — all existing V2 components.
| Actions | ||||
|---|---|---|---|---|
OROlivia RhyeWillow 12 · Residential Maintenance · John Smith | Today | |||
AJAmelia JohnsonWillow 14 · Residential Cleaning · Sarah Thomas | Yesterday | |||
MGMartin GuptaOak 03 · Respite Security · David Brown | 12 Aug 2026 | |||
PRPriya RamanOak 07 · Residential Cleaning · Sarah Thomas | Today |
Showing 1–4 of 5 residents
Sticky header for long tables
The header stays visible while the body scrolls inside a labelled, keyboard-reachable scroll region. The header style is unchanged.
OROlivia RhyeWillow 12 · Residential | Today | |
AJAmelia JohnsonWillow 14 · Residential | Yesterday | |
MGMartin GuptaOak 03 · Respite | 12 Aug 2026 | |
PRPriya RamanOak 07 · Residential | Today | |
AOAde OkaforBeech 02 · Residential | 2 Aug 2026 | |
OROlivia RhyeWillow 12 · Residential | Today | |
AJAmelia JohnsonWillow 14 · Residential | Yesterday | |
MGMartin GuptaOak 03 · Respite | 12 Aug 2026 | |
PRPriya RamanOak 07 · Residential | Today | |
AOAde OkaforBeech 02 · Residential | 2 Aug 2026 |
Showing 1–10 of 10 residents
Table toolbar
The lightweight strip above a table: search, filter controls, active-filter chips and a trailing action cluster for view controls, export and the primary action. Same component as FilterBar, named for its table context.
import { TableToolbar } from "@/design-system";
When to use
- Any table that can be searched, filtered or exported.
- Screens whose primary action creates a record shown in the table.
When not to use
- Tables with fewer rows than fit on one screen and no actions.
- As a page header — use PageHeader for titles and breadcrumbs.
Search, filters, date range, export and primary action
Sort control
A compact toolbar trigger that opens a popover of sortable fields, each with its own direction pair. The active sort — field and direction together — is always explicit, unlike header-only sorting where direction hides behind an arrow.
import { SortControl } from "@/design-system";
When to use
- Tables or lists sorted by several independent fields (created, updated, enquiry date).
- Toolbars where header sorting alone can't express the available sorts.
When not to use
- A single sortable column — a sortable TableHead is simpler.
- Choosing a saved view or grouping — use Select or Tabs.
Date-field sorting with committed selection
Sorted by created, earliest to latest
Name sorting with custom direction labels
Table states
Every table renders the same five states from the shared V2 state components — skeleton rows while loading, EmptyState when nothing exists, NoResultsState when filters exclude everything, ErrorState with a retry, and the selected state on rows.
import { EmptyState, NoResultsState, ErrorState, Skeleton } from "@/design-system";
When to use
- Any table backed by a request that can be slow, empty or fail.
When not to use
- Static reference tables shipped with the page — they only have the default state.
Walk the lifecycle
Standalone state components
No residents yet
Add your first resident to get started.
No residents found
Try changing your search or filters.
Unable to load residents
Please try again.
Table primitives
When a table needs bespoke structure — grouped rows, a footer total, a nested layout — compose the primitives directly. They carry the same density, borders, header typography, hover, selected and disabled treatment as DataTable.
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableRowActions, TableCellText } from "@/design-system";
When to use
- Bespoke structures DataTable does not model.
- Small static tables inside a record view or dialog.
When not to use
- Anything needing sorting, filtering, selection or pagination — use DataTable and get them for free.
Composed rows with selection, truncation and a disabled row
| Resident | Status | Actions | |
|---|---|---|---|
OROlivia RhyeWillow 12 · Residential | |||
AJAmelia JohnsonWillow 14 · Residential | |||
MGMartin GuptaOak 03 · Respite |
Cells, sorting & selection
What goes in a cell, and how the interactive columns behave.
| Content | Component | Alignment | Notes |
|---|---|---|---|
| Text | Plain text or TableCellText | Left | Truncate long free text, never identifiers |
| Number | Plain text with tabular-nums | Right | Right alignment makes magnitudes comparable |
| Date | Formatted text | Right | Relative for recent, absolute beyond a week |
| Status | Badge with an icon | Left | Never a bespoke pill; icon + text, not colour alone |
| Person | Avatar + name + secondary line | Left | Avatar size small, 12px gap |
| Icon + text | Lucide icon at icon-xs + label | Left | Icon is decorative; the label carries meaning |
| Link | Anchor with the shared link token | Left | Same hover / focus treatment as body links |
| Actions | TableRowActions + IconButton / DropdownMenu | Right | One or two visible, the rest in More actions |
| Interaction | Result | Announced as |
|---|---|---|
| Activate an unsorted header | Sorts ascending | aria-sort=ascending + “sorted ascending” |
| Activate an ascending header | Sorts descending | aria-sort=descending + “sorted descending” |
| Activate a descending header | Clears sorting | aria-sort=none + “not sorted” |
| Select all | Selects every selectable row on the page | Checked; partial selection reads indeterminate |
| Select a row | Adds it to the selection and tints the row | Checkbox names the record |
| Selection > 0 | Bulk action bar appears above the table | Polite count in a labelled region |
| Disabled row | Cannot be selected or acted on | aria-disabled on the row |
Responsive behaviour
Tables adapt by prioritising columns, not by shrinking every column until nothing is legible.
| Breakpoint | Columns | Toolbar | Actions |
|---|---|---|---|
| Desktop ≥ 1280px | All columns, including long notes | Search, filters and actions inline | Inline icon buttons + menu |
| Tablet 768–1279px | Notes and staff hidden via hideBelow | Filters inline, actions wrap | Icon button + More actions |
| Mobile < 768px | Identity, status and date; the rest stacks under the first cell | Filters collapse behind a Filters toggle | More actions menu only |
Horizontal scroll and truncation
The scroll container is scoped to the table, labelled and focusable, so a keyboard user can reach and scroll it and the page itself never scrolls sideways. Content is never clipped silently: values that truncate use an ellipsis and expose the full text through the shared Tooltip, and identifiers and actions never truncate at all.
Do / Don't
Do
- Give every table a caption, an itemLabel and explicit empty / no-results / error states.
- Use EmptyState when no records exist and NoResultsState when filters exclude them — they are different messages.
- Show skeleton rows in the real layout while loading.
- Right-align numbers and dates; left-align identity and status.
- Keep one or two row actions visible and move the rest into a More actions menu.
- Prioritise columns with hideBelow and keep the hidden values in mobileSummary.
Don't
- Don't build a table-specific checkbox, pagination, badge, input or error component.
- Don't use the generic empty state for a filtered-out result set.
- Don't swap the whole table for a spinner while loading.
- Don't rely on colour or arrow position alone to convey sort direction or status.
- Don't vary row heights inside one table without a content reason.
- Don't hide critical information on small screens without a stacked alternative.