Skip to content

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-corporate/ui/data-table
Open ↗

Source

"use client";

import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";

export interface DataTableCustomer {
  id: string;
  name: string;
  email: string;
  balance: number;
  invoices: number;
  lastPayment: string;
}

export interface DataTableProps {
  rows?: DataTableCustomer[];
  pageSize?: number;
  caption?: string;
  onSelectionChange?: (ids: string[]) => void;
  className?: string;
}

const DEFAULT_ROWS: DataTableCustomer[] = [
  { id: "c1", name: "Northwind Traders", email: "ap@northwind.com", balance: 18400, invoices: 12, lastPayment: "2026-09-22" },
  { id: "c2", name: "Lumen Health", email: "finance@lumen.health", balance: 6250, invoices: 8, lastPayment: "2026-09-18" },
  { id: "c3", name: "Parcel Logistics", email: "billing@parcel.io", balance: 42900, invoices: 21, lastPayment: "2026-08-30" },
  { id: "c4", name: "Brightline Studio", email: "ops@brightline.co", balance: 0, invoices: 4, lastPayment: "2026-09-20" },
  { id: "c5", name: "Kestrel Analytics", email: "ap@kestrel.ai", balance: 12780, invoices: 9, lastPayment: "2026-09-02" },
  { id: "c6", name: "Oakridge Partners", email: "accounts@oakridge.com", balance: 3120, invoices: 6, lastPayment: "2026-09-15" },
  { id: "c7", name: "Quanta Labs", email: "finance@quanta.dev", balance: 27450, invoices: 17, lastPayment: "2026-08-12" },
  { id: "c8", name: "Heliograph", email: "billing@heliograph.io", balance: 950, invoices: 3, lastPayment: "2026-09-23" },
];

type Key = "name" | "balance" | "invoices" | "lastPayment";

/** Customer data table: sortable headers (aria-sort), row checkboxes with select-all (indeterminate), bulk-action bar when rows are selected, mono amounts and pagination. */
export function DataTable({ rows = DEFAULT_ROWS, pageSize = 5, caption = "Customers", onSelectionChange, className }: DataTableProps) {
  const [sort, setSort] = useState<{ key: Key; dir: "asc" | "desc" }>({ key: "balance", dir: "desc" });
  const [sel, setSel] = useState<string[]>([]);
  const [page, setPage] = useState(0);
  const sorted = useMemo(
    () =>
      [...rows].sort((a, b) => {
        const r = a[sort.key] < b[sort.key] ? -1 : a[sort.key] > b[sort.key] ? 1 : 0;
        return sort.dir === "asc" ? r : -r;
      }),
    [rows, sort],
  );
  const pages = Math.max(1, Math.ceil(sorted.length / pageSize));
  const view = sorted.slice(page * pageSize, page * pageSize + pageSize);
  const allOn = view.length > 0 && view.every((r) => sel.includes(r.id));
  const someOn = view.some((r) => sel.includes(r.id));
  const update = (next: string[]) => {
    setSel(next);
    onSelectionChange?.(next);
  };
  const fmt = (n: number) => n.toLocaleString("en-US", { style: "currency", currency: "USD" });
  const date = (d: string) => new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
  const th = (k: Key, label: string, right?: boolean) => {
    const on = sort.key === k;
    const Icon = on ? (sort.dir === "asc" ? ArrowUp : ArrowDown) : ChevronsUpDown;
    return (
      <th
        scope="col"
        aria-sort={on ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
        className={cn("px-4 py-2.5 font-medium", right && "text-right")}
      >
        <button
          type="button"
          onClick={() => setSort({ key: k, dir: on && sort.dir === "desc" ? "asc" : "desc" })}
          className={cn("da-focus inline-flex items-center gap-1 rounded-da-sm hover:text-da-fg", on && "text-da-fg")}
        >
          {label}
          <Icon aria-hidden className="size-3.5" />
        </button>
      </th>
    );
  };
  return (
    <div className={cn("da-stroke overflow-hidden rounded-da-lg bg-da-surface text-da-surface-fg shadow-da-sm", className)}>
      <div className={cn("flex h-12 items-center gap-3 border-b border-da-border px-4 text-sm", sel.length ? "bg-da-primary/5" : "bg-da-surface")}>
        {sel.length ? (
          <>
            <span className="font-semibold">{sel.length} selected</span>
            <button type="button" className="da-focus da-stroke h-8 rounded-da-md bg-da-surface px-3 font-semibold">
              Send statement
            </button>
            <button type="button" className="da-focus da-stroke h-8 rounded-da-md bg-da-surface px-3 font-semibold">
              Export
            </button>
            <button type="button" onClick={() => update([])} className="da-focus ml-auto rounded-da-sm text-da-muted-fg hover:text-da-fg">
              Clear
            </button>
          </>
        ) : (
          <span className="font-semibold">
            {caption} <span className="font-normal text-da-muted-fg">· {rows.length}</span>
          </span>
        )}
      </div>
      <div className="relative overflow-x-auto" role="region" aria-label={`${caption} table`} tabIndex={0}>
        <table className="w-full min-w-[640px] text-sm">
          <caption className="sr-only">{caption}, sortable</caption>
          <thead className="bg-da-surface-2 text-left text-xs text-da-muted-fg">
            <tr>
              <th scope="col" className="w-10 px-4 py-2.5">
                <input
                  type="checkbox"
                  aria-label="Select all on this page"
                  checked={allOn}
                  ref={(el) => {
                    if (el) el.indeterminate = someOn && !allOn;
                  }}
                  onChange={() => update(allOn ? sel.filter((id) => !view.some((r) => r.id === id)) : [...new Set([...sel, ...view.map((r) => r.id)])])}
                  className="da-focus size-4 accent-(--da-primary)"
                />
              </th>
              {th("name", "Customer")}
              {th("invoices", "Invoices", true)}
              {th("balance", "Open balance", true)}
              {th("lastPayment", "Last payment", true)}
            </tr>
          </thead>
          <tbody className="divide-y divide-da-border">
            {view.map((r) => {
              const on = sel.includes(r.id);
              return (
                <tr key={r.id} className={cn("da-transition", on ? "bg-da-primary/5" : "hover:bg-da-surface-2")}>
                  <td className="px-4 py-3">
                    <input
                      type="checkbox"
                      aria-label={`Select ${r.name}`}
                      checked={on}
                      onChange={() => update(on ? sel.filter((x) => x !== r.id) : [...sel, r.id])}
                      className="da-focus size-4 accent-(--da-primary)"
                    />
                  </td>
                  <td className="px-4 py-3">
                    <span className="block font-medium">{r.name}</span>
                    <span className="block text-xs text-da-muted-fg">{r.email}</span>
                  </td>
                  <td className="px-4 py-3 text-right font-da-mono tabular-nums">{r.invoices}</td>
                  <td className={cn("px-4 py-3 text-right font-da-mono tabular-nums", r.balance === 0 && "text-da-muted-fg")}>{fmt(r.balance)}</td>
                  <td className="px-4 py-3 text-right text-da-muted-fg">{date(r.lastPayment)}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      <nav aria-label="Pagination" className="flex items-center justify-between border-t border-da-border px-4 py-3 text-sm text-da-muted-fg">
        <span>
          {page * pageSize + 1}–{Math.min(sorted.length, (page + 1) * pageSize)} of {sorted.length}
        </span>
        <span className="flex gap-1">
          <button
            type="button"
            aria-label="Previous page"
            disabled={page === 0}
            onClick={() => setPage(page - 1)}
            className="da-focus da-stroke grid size-8 place-items-center rounded-da-md bg-da-surface disabled:opacity-40"
          >
            <ChevronLeft aria-hidden className="size-4" />
          </button>
          <button
            type="button"
            aria-label="Next page"
            disabled={page >= pages - 1}
            onClick={() => setPage(page + 1)}
            className="da-focus da-stroke grid size-8 place-items-center rounded-da-md bg-da-surface disabled:opacity-40"
          >
            <ChevronRight aria-hidden className="size-4" />
          </button>
        </span>
      </nav>
    </div>
  );
}

export default DataTable;

modules/neo-corporate/ui/data-table/index.tsx

Props

PropTypeDefaultDescription
rowsDataTableCustomer[]—Rows.
pageSizenumber—Page Size.
captionstring—Caption.
onSelectionChange(ids: string[]) => void—Callback.
classNamestring—Extra classes on the root.

Other table variants in Neo Corporate

Table in other art directions

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.

BrutalistTable

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.

BrutalistTable

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.

BrutalistTable

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.

BrutalistTable

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

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