Skip to content

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-table
Open ↗

Source

"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

PropTypeDefaultDescription
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.
toolbarReactNode—Content on the right of the caption bar.
emptyMessagestring"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).
classNamestring—Classes on the wrapper.

Other table variants in Brutalist

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.

GlassTable

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.

GlassTable

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).

GlassTable

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.

GlassTable

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.

MinimalTable

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.

MinimalTable

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.

MinimalTable

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.

MinimalTable

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.

Mono CleanTable

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.

Mono CleanTable

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 ↗.

Mono CleanTable

Sites Table

Sites dashboard table: name + domain link, dot status in mono, template, updated time and a “…” Radix dropdown (open, duplicate, archive); hairline rows.

Mono CleanTable

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.

Neo CorporateTable

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.

Neo CorporateTable

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).

Neo CorporateTable

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.

Neo CorporateTable

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.

Organic SoftTable

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.

Organic SoftTable

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.

Organic SoftTable

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”.

Organic SoftTable

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.

Soft FlatTable

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.

Soft FlatTable

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).

Soft FlatTable

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.

Soft FlatTable