Design 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

Structure and tokens shared by every table
PartTreatmentToken source
Container1px subtle border, radius-md, horizontal scroll--color-border-subtle / --ds-radius-md
Header celloverline (11px uppercase), tertiary text, tinted header band--ds-text-overline / --color-background-subtle
Row divider1px subtle horizontal rule — no vertical grid lines--color-border-subtle
Row heightcompact 8px, default 12px, comfortable 16px vertical padding--ds-space-2 / 3 / 4
Hoversurface-hover fill--color-surface-hover
Selectedprimary-subtle fill, persists over hover--color-primary-subtle
Disabled rowpointer-events off, disabled text, still readable--color-text-disabled
Sort glyphArrowUp / ArrowDown / ArrowUpDown at icon-xs--size-icon-xs
Focusshared focus ring on every control and the scroll regionds-focus-ring
Footerresults 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.

Residents, their service and assigned staff
Actions
OROlivia RhyeWillow 12 · Residential
Maintenance · John Smith
ActiveToday
AJAmelia JohnsonWillow 14 · Residential
Cleaning · Sarah Thomas
PendingYesterday
MGMartin GuptaOak 03 · Respite
Security · David Brown
Inactive12 Aug 2026
PRPriya RamanOak 07 · Residential
Cleaning · Sarah Thomas
ActiveToday

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.

Residents with a sticky header
OROlivia RhyeWillow 12 · Residential
ActiveToday
AJAmelia JohnsonWillow 14 · Residential
PendingYesterday
MGMartin GuptaOak 03 · Respite
Inactive12 Aug 2026
PRPriya RamanOak 07 · Residential
ActiveToday
AOAde OkaforBeech 02 · Residential
Active2 Aug 2026
OROlivia RhyeWillow 12 · Residential
ActiveToday
AJAmelia JohnsonWillow 14 · Residential
PendingYesterday
MGMartin GuptaOak 03 · Respite
Inactive12 Aug 2026
PRPriya RamanOak 07 · Residential
ActiveToday
AOAde OkaforBeech 02 · Residential
Active2 Aug 2026

Showing 1–10 of 10 residents

Accessibility

  • Real table semantics: caption, scope=col headers, one row per record.
  • Sort buttons expose aria-sort on the header plus a hidden state sentence, so direction is never icon-only.
  • The select-all checkbox reflects partial selection as indeterminate and names its scope.
  • Every row action is a labelled IconButton or menu item, reachable by keyboard with a visible focus ring.
  • Result counts and selection counts are announced through polite live regions.

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

Accessibility

  • The toolbar is a labelled search region; every control keeps its own visible label or aria-label.
  • Filters collapse behind a labelled Filters toggle below md, with the active count in a Badge.
  • Active filters render as removable chips, so applied state is never implicit.

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

Accessibility

  • The trigger is a labelled button; its accessible name includes the active field once sorted.
  • Each direction pair is a SegmentedControl with its own aria-label naming the field.
  • Direction labels are words, never arrows alone, so direction is never conveyed by icon position.

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

Resident table lifecycle states

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.

Accessibility

  • Loading shows skeleton rows in the real layout rather than replacing the table with a spinner, so the header and column count stay announced.
  • Empty, no-results and error states occupy one cell spanning every column, keeping the table structure valid.
  • The error state names the failure and offers a Retry button — not an icon alone.

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

Residents composed from the table primitives
ResidentStatusActions
OROlivia RhyeWillow 12 · Residential
Active
AJAmelia JohnsonWillow 14 · Residential
Pending
MGMartin GuptaOak 03 · Respite
Inactive

Accessibility

  • Always pass a caption: it names the table for screen readers even when visually hidden.
  • Keep one <th scope="col"> per column and never fake a header row with styled cells.
  • Disabled rows set aria-disabled and keep readable text rather than fading below contrast.

Cells, sorting & selection

What goes in a cell, and how the interactive columns behave.

Cell types and the component each one uses
ContentComponentAlignmentNotes
TextPlain text or TableCellTextLeftTruncate long free text, never identifiers
NumberPlain text with tabular-numsRightRight alignment makes magnitudes comparable
DateFormatted textRightRelative for recent, absolute beyond a week
StatusBadge with an iconLeftNever a bespoke pill; icon + text, not colour alone
PersonAvatar + name + secondary lineLeftAvatar size small, 12px gap
Icon + textLucide icon at icon-xs + labelLeftIcon is decorative; the label carries meaning
LinkAnchor with the shared link tokenLeftSame hover / focus treatment as body links
ActionsTableRowActions + IconButton / DropdownMenuRightOne or two visible, the rest in More actions
Sorting and selection behaviour
InteractionResultAnnounced as
Activate an unsorted headerSorts ascendingaria-sort=ascending + “sorted ascending”
Activate an ascending headerSorts descendingaria-sort=descending + “sorted descending”
Activate a descending headerClears sortingaria-sort=none + “not sorted”
Select allSelects every selectable row on the pageChecked; partial selection reads indeterminate
Select a rowAdds it to the selection and tints the rowCheckbox names the record
Selection > 0Bulk action bar appears above the tablePolite count in a labelled region
Disabled rowCannot be selected or acted onaria-disabled on the row

Responsive behaviour

Tables adapt by prioritising columns, not by shrinking every column until nothing is legible.

Responsive strategy per breakpoint
BreakpointColumnsToolbarActions
Desktop ≥ 1280pxAll columns, including long notesSearch, filters and actions inlineInline icon buttons + menu
Tablet 768–1279pxNotes and staff hidden via hideBelowFilters inline, actions wrapIcon button + More actions
Mobile < 768pxIdentity, status and date; the rest stacks under the first cellFilters collapse behind a Filters toggleMore 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.