Data Table
Typed generic table with a yellow caption bar + toolbar slot, ruled rows, sortable columns (asc → desc → none), custom cell renderers, mobile column hiding, empty state and optional row click.
brutalist/ui/data-tableSource
"use client";
import { useId, useMemo, useState, type ReactNode } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DataTableColumn<T> {
/** Unique key; also used to read `row[key]` when no `cell` is given. */
key: string;
header: string;
cell?: (row: T) => ReactNode;
/** Value used for sorting; enables the sort button on the header. */
sortValue?: (row: T) => string | number;
align?: "left" | "right" | "center";
/** Hide the column below the `sm` breakpoint. */
hideOnMobile?: boolean;
className?: string;
}
export interface DataTableProps<T> {
columns: DataTableColumn<T>[];
rows: T[];
getRowId: (row: T) => string;
/** Accessible caption (visible above the table). */
caption: string;
/** Optional element aligned right of the caption (filters, buttons…). */
toolbar?: ReactNode;
emptyMessage?: string;
initialSort?: { key: string; direction: "asc" | "desc" };
onRowClick?: (row: T) => void;
className?: string;
}
type SortState = { key: string; direction: "asc" | "desc" } | null;
export function DataTable<T>({
columns,
rows,
getRowId,
caption,
toolbar,
emptyMessage = "Nothing here yet.",
initialSort,
onRowClick,
className,
}: DataTableProps<T>) {
const [sort, setSort] = useState<SortState>(initialSort ?? null);
const captionId = useId();
const sorted = useMemo(() => {
if (!sort) return rows;
const col = columns.find((c) => c.key === sort.key);
if (!col?.sortValue) return rows;
const get = col.sortValue;
const factor = sort.direction === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const va = get(a);
const vb = get(b);
return (va < vb ? -1 : va > vb ? 1 : 0) * factor;
});
}, [rows, columns, sort]);
const toggleSort = (key: string) =>
setSort((s) => (s?.key !== key ? { key, direction: "asc" } : s.direction === "asc" ? { key, direction: "desc" } : null));
const alignClass = (a: DataTableColumn<T>["align"]) => (a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left");
return (
<div className={cn("da-stroke bg-da-surface text-da-surface-fg shadow-da-md", className)}>
<div className="da-stroke-b flex flex-wrap items-center justify-between gap-3 bg-da-primary px-4 py-3 text-da-primary-fg">
<p id={captionId} className="font-da-display text-lg tracking-da-display uppercase">
{caption}
</p>
{toolbar}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm" aria-labelledby={captionId}>
<thead>
<tr className="da-stroke-b bg-da-surface-2">
{columns.map((col) => {
const active = sort?.key === col.key;
const ariaSort = active ? (sort.direction === "asc" ? "ascending" : "descending") : col.sortValue ? "none" : undefined;
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort}
className={cn(
"px-4 py-3 font-da-mono text-[11px] font-bold tracking-da-label whitespace-nowrap uppercase",
alignClass(col.align),
col.hideOnMobile && "hidden sm:table-cell",
)}
>
{col.sortValue ? (
<button
type="button"
onClick={() => toggleSort(col.key)}
className={cn(
"da-focus inline-flex items-center gap-1.5 uppercase hover:underline hover:decoration-2 hover:underline-offset-4",
col.align === "right" && "flex-row-reverse",
)}
>
{col.header}
{active ? (
sort.direction === "asc" ? (
<ArrowUp aria-hidden className="size-3.5" strokeWidth={3} />
) : (
<ArrowDown aria-hidden className="size-3.5" strokeWidth={3} />
)
) : (
<ArrowUpDown aria-hidden className="size-3.5 opacity-50" strokeWidth={2.5} />
)}
</button>
) : (
col.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center font-bold text-da-muted-fg">
{emptyMessage}
</td>
</tr>
) : (
sorted.map((row) => (
<tr
key={getRowId(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={cn("[&+tr]:da-stroke-t da-transition hover:bg-da-muted", onRowClick && "cursor-pointer")}
>
{columns.map((col) => (
<td
key={col.key}
className={cn("px-4 py-3.5 align-middle", alignClass(col.align), col.hideOnMobile && "hidden sm:table-cell", col.className)}
>
{col.cell ? col.cell(row) : String((row as Record<string, unknown>)[col.key] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
export default DataTable;
modules/brutalist/ui/data-table/index.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns* | DataTableColumn<T>[] | — | { key, header, cell?, sortValue?, align?, hideOnMobile?, className? }. |
| rows* | T[] | — | Data rows. |
| getRowId* | (row: T) => string | — | Stable key for each row. |
| caption* | string | — | Visible title, also labels the table for assistive tech. |
| toolbar | ReactNode | — | Content on the right of the caption bar. |
| emptyMessage | string | "Nothing here yet." | Shown when rows is empty. |
| initialSort | { key: string; direction: "asc" | "desc" } | — | Initial sort. |
| onRowClick | (row: T) => void | — | Makes rows clickable (keep a real link/button in a cell for keyboard users). |
| className | string | — | Classes on the wrapper. |
Other table variants in Brutalist
Expandable Table
Table with expandable rows: a ruled chevron square per row reveals a detail row underneath; the open row floods yellow. Single or multiple open rows.
Leaderboard Table
Ranked list with ink header, colored podium squares for the top 3, proportional background bars behind each row, rank-change chips (▲/▼) and big tabular values. Sorts by value automatically.
Simple Table
Server-safe static table: uppercase caption, ink header row with column rules, zebra rows, right-aligned mono numeric columns and an optional yellow totals footer.
Table in other art directions
Action Items Table
Action items table with Open / Done / All filter pills and counts, gradient checkboxes that strike tasks through, owner, due chip (overdue in red) and source meeting; empty state.
Data Table
Frosted table panel with title, description, pill search field and actions slot; rows are separated translucent rounded strips that brighten on hover. Typed generic columns, sorting, mobile hiding, empty state.
Meetings Table
Recent meetings glass table: title with gradient video icon and platform, date, mono duration, participant avatar stack and a recap status pill (ready, processing, private).
Usage Table
Team usage glass table: member, inline gradient bar of recorded hours relative to the top member, meetings count and hours saved, with a tinted totals row.
Data Table
Typed generic table with hairline rows, sortable headers, optional row selection (select-all with indeterminate state) and a floating bulk-action bar. Custom cells, mobile column hiding, empty state.
Grouped Table
Services table grouped by team: collapsible tinted group rows with service count and alert subtotal, then rows with status dot, mono service name, owner, alerts and MTTA.
Paginated Table
Audit-log table with client-side pagination: page-size select, “1–10 of 47” range, numbered pages with ellipsis and prev/next; mono timestamps and IPs, horizontal scroll on mobile.
Uptime Table
Status-page uptime table: one row per service with 30 daily bars colored by threshold (≥ 99.99 %, ≥ 99 %, below), a current-status dot, the 30-day average and a legend.
Changelog Table
Release log laid out as a table: mono version + ISO date column, a square type tag (New in signal color, Improved outlined, Fixed gray) and dash-listed notes; hairline rows.
Data Table
Analytics table in pure typography: mono headers that sort (↑/↓ glyph, aria-sort), hairline rows, tabular numbers, inline hairline bars for share of views, and an ink total row.
Projects Table
Studio project index (the classic portfolio list): large titles with client, discipline and year in mono columns; each row is one link that inverts to ink on hover with an ↗.
Sites Table
Sites dashboard table: name + domain link, dot status in mono, template, updated time and a “…” Radix dropdown (open, duplicate, archive); hairline rows.
Approvals Table
Bill approval queue: vendor + requester, category, mono amount and inline Approve / Reject buttons that turn the row into a decided state (with Undo); pending count in the header.
Data Table
Customer data table: sortable headers (aria-sort), row checkboxes with select-all (indeterminate), bulk-action bar when rows are selected, mono amounts and pagination.
Invoice Table
Receivables table with status pills, days-late aging text, mono amounts, a totals footer and a per-row Radix dropdown (view, send reminder, duplicate, void).
Ledger Table
General-ledger table grouped by date: journal ref and memo, GL account, debit/credit columns in mono, credits indented, and balanced totals row with a “Balanced” check.
Actions Table
Reduction plan table: round checkboxes mark actions done (row softens), owner and due date, mono savings, and a progress header showing tonnes secured toward the target.
Emissions Table
Emissions by category: rounded cream table with scope leaf chips, inline sage share bars behind the tonnes, mono figures, colored year-over-year change and a total row.
Monthly Table
Month × scope heat table: each cell tinted by intensity along a sand→sage→forest scale with mono values; row totals and a legend; readable without color.
Supplier Table
Supplier engagement table: filter pills by status, arch avatars, share of footprint, status pill and 3-dot data quality meter, with a per-row “Nudge” pill that turns into “Sent”.
Activity Table
Activity log table: kind icon in a pastel circle, emoji avatar and sentence (“Ana completed …”), board chip and relative time; rows highlight softly on hover.
Simple Table
Plain friendly data table in a rounded white card: soft header well, roomy rows, gentle zebra and right-aligned numbers; generic columns and rows props.
Task Table
“My tasks” table where each row is a soft rounded strip: round done checkbox, title (struck when done), pastel board chip, owner initial avatar and due chip (red when overdue).
Workload Table
Team workload heat table: a row per person (emoji + name), one rounded cell per weekday tinted mint → butter → peach by number of tasks, with a legend.