Skip to content

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.

brutalist/ui/expandable-table
Open ↗

Source

"use client";

import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ExpandableTableRow {
  id: string;
  cells: ReactNode[];
  /** Content revealed below the row. */
  detail: ReactNode;
}

export interface ExpandableTableProps {
  caption: string;
  headers: string[];
  rows: ExpandableTableRow[];
  /** Allow several rows open at once. */
  multiple?: boolean;
  className?: string;
}

export function ExpandableTable({ caption, headers, rows, multiple = false, className }: ExpandableTableProps) {
  const [open, setOpen] = useState<string[]>([]);
  const toggle = (id: string) =>
    setOpen((o) => (o.includes(id) ? o.filter((x) => x !== id) : multiple ? [...o, id] : [id]));

  return (
    <div className={cn("da-stroke overflow-x-auto bg-da-surface text-da-surface-fg shadow-da-md", className)}>
      <table className="w-full border-collapse text-sm">
        <caption className="da-stroke-b px-4 py-3 text-left font-da-display text-lg tracking-da-display uppercase">{caption}</caption>
        <thead>
          <tr className="da-stroke-b bg-da-surface-2">
            <th scope="col" className="w-12">
              <span className="sr-only">Expand</span>
            </th>
            {headers.map((h) => (
              <th key={h} scope="col" className="px-4 py-2.5 text-left font-da-mono text-[11px] font-bold tracking-da-label uppercase">
                {h}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => {
            const isOpen = open.includes(r.id);
            return (
              <Fragment key={r.id}>
                <tr className={cn(i > 0 && "da-stroke-t", isOpen && "bg-da-primary text-da-primary-fg")}>
                  <td className="pl-3">
                    <button
                      type="button"
                      onClick={() => toggle(r.id)}
                      aria-expanded={isOpen}
                      aria-controls={`detail-${r.id}`}
                      aria-label={isOpen ? "Collapse row" : "Expand row"}
                      className="da-focus da-stroke grid size-7 place-items-center bg-da-surface text-da-surface-fg"
                    >
                      <ChevronRight aria-hidden className={cn("size-4 transition-transform duration-(--da-duration)", isOpen && "rotate-90")} strokeWidth={3} />
                    </button>
                  </td>
                  {r.cells.map((c, ci) => (
                    <td key={ci} className={cn("px-4 py-3", ci === 0 && "font-bold")}>
                      {c}
                    </td>
                  ))}
                </tr>
                <tr id={`detail-${r.id}`} hidden={!isOpen} className="da-stroke-t bg-da-surface-2">
                  <td colSpan={headers.length + 1} className="px-4 py-4 pl-16">
                    {r.detail}
                  </td>
                </tr>
              </Fragment>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

export default ExpandableTable;

modules/brutalist/ui/expandable-table/index.tsx

Props

PropTypeDefaultDescription
caption*string—Visible caption.
headers*string[]—Column headers (after the expand column).
rows*ExpandableTableRow[]—{ id, cells: ReactNode[], detail: ReactNode }[].
multiplebooleanfalseAllow several open rows.
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