Components / Application
Data Table
Compound components for all tabular data in the app. Provides consistent chrome across list views, with optional toolbar controls whose complexity scales with the size and complexity of the dataset.
When to use what
| Pattern | When | Examples |
|---|---|---|
| Table, no toolbar | Fewer than ~8 rows, not searchable by name, scanned at a glance. | Insurance policies per property, committee members, meeting agenda items. |
| Table + search | 8 to ~30 rows of named entities where the user may know who/what they want. | Owners roll (lots 1–42), contractor list, document list. |
| Table + compound filters | More than ~30 rows, or multi-axis filtering needed (status AND location AND health). | Portfolio scheme index, arrears across all properties, compliance register. |
| Toolbar page actions | The view needs create, upload, or export alongside sort and column controls. | New document + Upload on a document register; Log issue on a maintenance list. Wrap in DataTableToolbarActions, not in the filter row. |
| Add card view toggle | Entities have strong visual identity or the list is browsed rather than compared. Cards degrade better on mobile for content-rich entities. | Scheme portfolio (schemes have icons, health colours). NOT owners roll (reference data — always compare columns). |
Examples
Plain table — no toolbar
5 insurance policies. No searching needed at this scale.
| Type | Insurer | Cover | Expiry | Status |
|---|---|---|---|---|
Building insurance | NRMA | $24.5M | 18 Jun 2026 | Renewal pending |
Public liability | NRMA | $20.0M | 18 Jun 2027 | Current |
Office bearers | QBE | $1.0M | 30 Sep 2026 | Current |
Workers compensation | Allianz | — | 31 Dec 2026 | Current |
Voluntary workers | Allianz | $500k | 31 Dec 2026 | Current |
import {
DataTable, DataTableRow, DataTableCell,
type DataTableColumn,
} from "@/components/ui/data-table"
const COLS: DataTableColumn[] = [
{ key: "type", label: "Type" },
{ key: "status", label: "Status" },
]
<DataTable columns={COLS}>
{rows.map(row => (
<DataTableRow key={row.id} onClick={() => open(row)}>
<DataTableCell>{row.type}</DataTableCell>
<DataTableCell><Badge>{row.status}</Badge></DataTableCell>
</DataTableRow>
))}
</DataTable>Searchable table: search only
14 owners from a 42-lot roll. Search helps locate a specific owner by name or lot number. The result count and Clear all link sit below the table via DataTableFooter, not in the toolbar.
| Lot | Owner | Type | Contact | Levy status |
|---|---|---|---|---|
1 | James Okonkwo 2 bed | Owner occ. | 0411 234 567 james@example.com | Current |
2 | Sarah Patel 1 bed | Investor | sarah.patel@example.com | Current |
5 | Chen Wei 2 bed | Owner occ. | 0422 345 678 | Current |
7 | Robert Davis 3 bed | Owner occ. | 0433 456 789 r.davis@example.com | Arrears |
8 | Lisa Wong 2 bed | Investor | No contact on file | Claim pending |
12 | Priya Singh 1 bed | Investor | No contact on file | Current |
14 | Margaret Chen 2 bed | Owner occ. | 0444 567 890 m.chen@example.com | Current |
18 | Patricia Kim 2 bed | Owner occ. | 0455 678 901 pkim@example.com | Current |
22 | David Park 2 bed | Owner occ. | 0466 789 012 | Current |
23 | Emma Thompson 1 bed | Investor | e.thompson@example.com | Arrears |
27 | Thomas Brown 2 bed | Owner occ. | No contact on file | Current |
31 | Jennifer Walsh 2 bed | Owner occ. | 0477 890 123 j.walsh@example.com | Current |
38 | Michael O'Brien 3 bed | Owner occ. | 0488 901 234 | Arrears |
42 | Alicia Nguyen 3 bed penthouse | Investor | No contact on file | Current |
import {
DataTable, DataTableRow, DataTableCell,
DataTableToolbar, DataTableSearch, DataTableFooter,
} from "@/components/ui/data-table"
const [search, setSearch] = useState("")
const filtered = rows.filter(r => r.name.includes(search))
<DataTable
columns={COLS}
toolbar={
<DataTableToolbar>
<DataTableSearch value={search} onChange={setSearch} placeholder="Search..." />
</DataTableToolbar>
}
footer={
<DataTableFooter
filtered={filtered.length}
total={rows.length}
noun="lots"
showClear={Boolean(search.trim())}
onClearAll={() => setSearch("")}
/>
}
>
{filtered.map(row => <DataTableRow key={row.id}>…</DataTableRow>)}
</DataTable>Compound filter bar — search, filter conditions, sort, columns
14 schemes across multiple states. The + Filter button adds a condition chip for each field. Each chip shows Field is [Value] with a coloured dropdown and a clear control. Sort and column visibility sit at the right of the filter bar, separated from page actions by DataTableToolbarActions. The result count and Clear all link always sit below the filtered content as plain metadata, outside the table border. Use the same placement for non-table views (calendars, card grids): toolbar at the top, content in the middle, footer at the bottom.
| Scheme | Location | Lots | Manager | Status | Health | Levy |
|---|---|---|---|---|---|---|
HV Harbour View Towers | QLD | 42 | Sarah Mitchell | Active | Warning | Due |
TQ The Quarter | QLD | 24 | Sarah Mitchell | Active | Critical | Overdue |
NE Northbridge Estate | QLD | 28 | Sarah Mitchell | Active | Warning | Due |
PR Parkside Residences | QLD | 16 | Sarah Mitchell | Active | Good | Current |
SG Sunset Gardens | NSW | 8 | Tom Walsh | Active | Good | Current |
MB Marina Bay Complex | NSW | 72 | Tom Walsh | Inactive | Critical | Overdue |
BJ Bondi Junction Centre | NSW | 92 | Tom Walsh | Active | Warning | Current |
RH Rosebay Heights | NSW | 28 | Tom Walsh | Active | Good | Due |
SR Skyline Residences | VIC | 88 | Priya Nair | Active | Good | Current |
FL Fitzroy Lane Terraces | VIC | 16 | Priya Nair | Active | Good | Current |
TG The Green Residences | VIC | 32 | Priya Nair | Active | Warning | Overdue |
WV Westfield Village | WA | 56 | Aisha Khan | Pending | Warning | Due |
MV Mountain View Estate | TAS | 18 | Aisha Khan | Active | Warning | Current |
CC City Central Tower | ACT | 64 | Aisha Khan | Active | Good | Current |
import {
DataTable, DataTableRow, DataTableCell,
DataTableCompoundToolbar, DataTableFooter, DataTableSearch,
DataTableToolbarActions,
} from "@/components/ui/data-table"
const anyFilters = Boolean(search.trim()) || conditions.some(c => c.value)
<DataTable
columns={COLS}
toolbar={
<DataTableCompoundToolbar
controls={
<>
<SortButton ... />
<ColumnsButton ... />
<DataTableToolbarActions>
<Button variant="outline" size="sm">Upload</Button>
<Button size="sm">New document</Button>
</DataTableToolbarActions>
</>
}
>
<DataTableSearch value={search} onChange={setSearch} />
{/* FilterConditionChip, AddFilterButton, etc. */}
</DataTableCompoundToolbar>
}
footer={
<DataTableFooter
filtered={filtered.length}
total={rows.length}
noun="documents"
showClear={anyFilters}
onClearAll={clearAll}
/>
}
>
{filtered.map(row => <DataTableRow key={row.id}>…</DataTableRow>)}
</DataTable>{/* Page CTAs belong in controls, wrapped in DataTableToolbarActions.
Keep sort/columns outside the wrapper; use outline for secondary, default for primary. */}
<DataTableCompoundToolbar
controls={
<>
<CompoundSortButton sortField={sortField} sortDir={sortDir} ... />
<CompoundColumnsButton columns={TOGGLE_COLS} hidden={hiddenCols} ... />
<DataTableToolbarActions>
<Button variant="outline" size="sm">Upload</Button>
<Button size="sm">
<Plus className="size-3.5" aria-hidden />
New document
</Button>
</DataTableToolbarActions>
</>
}
>
<DataTableSearch ... />
{/* filter chips */}
</DataTableCompoundToolbar>
{/* Do not put create/upload buttons in the filter row (children).
Do not mix them with DataTableFilterChips — chips filter data; actions change it. */}Toolbar page actions
Create, upload, and export buttons live in the controls slot of DataTableCompoundToolbar, wrapped in DataTableToolbarActions. The left border separates page actions from sort and column toggles. Use variant="outline" for secondary actions (Upload, Export) and one default Button for the primary action (New document). Max two buttons in the group. Never place these in the filter row beside search or condition chips.
Filter row (children)
Search, filter condition chips, + Filter. These refine what is shown.
Controls row (controls)
Sort, Columns, then DataTableToolbarActions for page CTAs. These change or add data.
Footer (footer prop)
Result count and Clear all only. Never put action buttons here.
Table with card view toggle
The same scheme data with a Table / Cards toggle. Use this pattern when entities have a visual identity (initials, health colour, icon) and users browse on mobile. Always keep the table view as the default — it is better for comparison and data-dense reading.
| Scheme | Location | Lots | Status | Health | Levy |
|---|---|---|---|---|---|
HV Harbour View Towers | QLD | 42 | Active | Warning | Due |
TQ The Quarter | QLD | 24 | Active | Critical | Overdue |
NE Northbridge Estate | QLD | 28 | Active | Warning | Due |
PR Parkside Residences | QLD | 16 | Active | Good | Current |
SG Sunset Gardens | NSW | 8 | Active | Good | Current |
MB Marina Bay Complex | NSW | 72 | Inactive | Critical | Overdue |
BJ Bondi Junction Centre | NSW | 92 | Active | Warning | Current |
RH Rosebay Heights | NSW | 28 | Active | Good | Due |
SR Skyline Residences | VIC | 88 | Active | Good | Current |
FL Fitzroy Lane Terraces | VIC | 16 | Active | Good | Current |
TG The Green Residences | VIC | 32 | Active | Warning | Overdue |
WV Westfield Village | WA | 56 | Pending | Warning | Due |
MV Mountain View Estate | TAS | 18 | Active | Warning | Current |
CC City Central Tower | ACT | 64 | Active | Good | Current |
Inside a Widget — framed={false}
When the table lives inside an existing bordered container (a Widget, a card) pass framed={false} so the outer rounded border and shadow are omitted. The header stripe and dividers still render.
{/* Inside a Widget — pass framed={false} to suppress the duplicate border */}
<Widget>
<WidgetHeader>…</WidgetHeader>
<WidgetContent flush>
<DataTable columns={COLS} framed={false}>
{rows.map(row => <DataTableRow key={row.id}>…</DataTableRow>)}
</DataTable>
</WidgetContent>
</Widget>Props
DataTable
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | DataTableColumn[] | — | Column definitions that drive the <thead> row. Each entry has key, label, align, width, and className. |
| children | ReactNode | — | One or more <DataTableRow> elements rendered as <tbody> content. |
| toolbar | ReactNode | undefined | Controls rendered above the table — wrap with <DataTableToolbar> or <DataTableCompoundToolbar>. Omit for small datasets that don't need filtering. |
| footer | ReactNode | undefined | Rendered below the table, outside the framed border. Use <DataTableFooter> for result count and Clear all. |
| framed | boolean | true | When true (default) wraps the table in a bordered container. Pass false when the table lives inside an existing border such as a Widget. |
| minWidth | string | "640px" | CSS min-width for the inner <table> element. Increase for wide column sets that need horizontal scroll. |
| emptyMessage | ReactNode | "No records found." | Shown as a spanning cell when children is empty. |
DataTableRow
| Prop | Type | Default | Description |
|---|---|---|---|
| onClick | () => void | undefined | When provided, adds a pointer cursor and hover highlight. |
| tone | "warning" | "danger" | undefined | Applies a translucent tinted background — use for at-risk rows. |
| className | string | undefined | Extra Tailwind classes. |
DataTableCell
| Prop | Type | Default | Description |
|---|---|---|---|
| align | "left" | "right" | "center" | "left" | Text alignment. Use right for numeric columns. |
| colSpan | number | undefined | HTML colSpan for spanning cells. |
| className | string | undefined | Extra Tailwind classes. |
DataTableSearch
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled search query. |
| onChange | (value: string) => void | — | Called on every keystroke with the new value. |
| placeholder | string | "Search..." | Input placeholder text. |
| icon | React.ElementType | Search | Leading icon component. Pass a domain-specific icon from lucide-react to add context (e.g. User for an owners roll). |
DataTableFilterChips
Simple inline pill chips — best for 3–5 mutually exclusive options shown permanently in the toolbar. For multi-field, additive filtering use the compound filter pattern shown in Example 3 above.
| Prop | Type | Default | Description |
|---|---|---|---|
| options | DataTableFilterOption[] | — | Array of { value, label } options. |
| value | string (generic T) | — | Currently active filter value. |
| onChange | (value: T) => void | — | Called when the user clicks a chip. |
| label | string | undefined | Optional prefix label shown before the chips, e.g. "Health:". |
DataTableToolbar
Wrapper component — renders a flex-wrap items-center gap-3 row. Pass as the toolbar prop of DataTable. Accepts any children.
DataTableCount
Renders N rows when unfiltered, N of M rows when filtered. Props: filtered, total, noun (defaults to “rows”). Do not place this in the toolbar. Pass these props to DataTableFooter so the count sits below the table or filtered view.
DataTableCompoundToolbar
Layout wrapper for the compound filter bar. Pass search and filter condition chips as children. Pass sort, column visibility, and page action buttons via controls. Wrap create/upload/export buttons in DataTableToolbarActions so they stay visually distinct from table controls.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Search input, active filter condition chips, and the + Filter button. |
| controls | ReactNode | undefined | Sort, Columns, and DataTableToolbarActions for page CTAs. |
DataTableToolbarActions
Groups page-level CTAs in the compound toolbar controls slot. Renders a left border divider so Upload, New document, Export, and similar buttons are clearly separate from sort and column toggles. Place after table controls; max two buttons with one primary (default variant) only.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | One or two Button elements: outline for secondary, default for primary. |
DataTableFooter
Table footer with result count and optional Clear all link. Renders below the table frame (or below any alternate content such as a card grid or calendar), outside the border. Pass as the footer prop of DataTable, or place it manually at the bottom of a space-y-3 wrapper when the content is not a table.
| Prop | Type | Default | Description |
|---|---|---|---|
| filtered | number | — | Number of rows after filtering. |
| total | number | — | Total rows before filtering. |
| noun | string | "rows" | Noun for the count label, e.g. issues, lots, schemes. |
| showClear | boolean | false | When true, renders Clear all beside the count. |
| onClearAll | () => void | undefined | Called when the user clicks Clear all. |
DataTableClearAll
Forest-coloured reset link. Usually rendered by DataTableFooter when showClear is true. Props: onClick.