Components

Data table

View in Figma

A semantic, sortless table driven by flat columns + rows props. Each column owns a cell render function, so status pills, trend numbers, and sparklines compose into cells while the table stays a thin, opinionated grid.

Install

npx shadcn@latest add @alix/dataTable

Preview

Active engagements
EngagementMarginMoMHealth
Project Atlas31.4%+1.2ptOn track
European auto OEM24.8%-0.6ptAt risk
US regional bank19.2%-2.1ptOff track

Usage

<DataTable
  columns={[
    { key: "client", header: "Client", cell: (r) => r.client, cellClassName: "font-medium" },
    { key: "margin", header: "Margin", numeric: true, cell: (r) => `${r.margin}%` },
    { key: "mom", header: "MoM", numeric: true, cell: (r) => <Delta value={r.mom} direction={r.momDirection} intent={r.momIntent} /> },
    { key: "status", header: "Health", cell: (r) => <Badge variant={r.tone} dot>{r.status}</Badge> },
  ]}
  rows={engagements}
/>

Examples

01

Dense financial rows

Dense padding keeps recurring numeric ledgers scannable without changing the column contract.

Practice margin by quarter
PracticeQ1Q2Margin
Turnaround$42.8M$45.1M31.4%
Performance$36.2M$38.7M28.9%
Transactions$29.5M$33.2M24.8%
<div className="w-full max-w-xl border border-border">
  <DataTable
    caption="Practice margin by quarter"
    dense
    rows={[
      { practice: "Turnaround", q1: "$42.8M", q2: "$45.1M", margin: "31.4%" },
      { practice: "Performance", q1: "$36.2M", q2: "$38.7M", margin: "28.9%" },
      { practice: "Transactions", q1: "$29.5M", q2: "$33.2M", margin: "24.8%" },
    ]}
    columns={[
      { key: "practice", header: "Practice", cell: (row) => row.practice, cellClassName: "font-medium" },
      { key: "q1", header: "Q1", numeric: true, cell: (row) => row.q1 },
      { key: "q2", header: "Q2", numeric: true, cell: (row) => row.q2 },
      { key: "margin", header: "Margin", numeric: true, cell: (row) => row.margin },
    ]}
  />
</div>
02

Sticky panel ledger

A flush Panel plus stickyHeader gives dense operational tables a stable dashboard frame.

Weekly delivery ledger

Priority workstreams

Weekly delivery ledger
WorkstreamOwnerHealthTrend
Liquidity forecastA. WardOn track
Supplier reviewM. KhanAt risk
Board materialsR. SilvaOn track
Cost baselineL. ChenOff track
<Panel title="Weekly delivery ledger" description="Priority workstreams" flush>
  <div className="max-h-72 overflow-auto">
    <DataTable
      caption="Weekly delivery ledger"
      stickyHeader
      rows={[
        { workstream: "Liquidity forecast", owner: "A. Ward", status: "On track", tone: "success" as const, trend: [12, 14, 13, 18, 19, 22] },
        { workstream: "Supplier review", owner: "M. Khan", status: "At risk", tone: "warning" as const, trend: [18, 17, 16, 14, 13, 12] },
        { workstream: "Board materials", owner: "R. Silva", status: "On track", tone: "success" as const, trend: [8, 10, 11, 13, 15, 18] },
        { workstream: "Cost baseline", owner: "L. Chen", status: "Off track", tone: "destructive" as const, trend: [20, 18, 17, 15, 11, 9] },
      ]}
      columns={[
        { key: "workstream", header: "Workstream", cell: (row) => row.workstream, cellClassName: "font-medium" },
        { key: "owner", header: "Owner", cell: (row) => row.owner },
        {
          key: "status",
          header: "Health",
          cell: (row) => <Badge variant={row.tone} dot size="sm">{row.status}</Badge>,
        },
        {
          key: "trend",
          header: "Trend",
          align: "right",
          width: "8rem",
          cell: (row) => <Sparkline data={row.trend} height={28} />,
        },
      ]}
    />
  </div>
</Panel>
03

Empty filtered result

DataTable does not own empty copy; pair the same frame with EmptyState when filters remove every row.

Filtered engagements

No matching engagements

Clear filters or widen the reporting period to bring rows back.

const filteredRows: Row[] = [];

<Panel title="Filtered engagements">
  {filteredRows.length > 0 ? (
    <DataTable caption="Filtered engagements" columns={columns} rows={filteredRows} />
  ) : (
    <EmptyState
      title="No matching engagements"
      description="Clear filters or widen the reporting period to bring rows back."
      action={<Button variant="outline">Clear filters</Button>}
    />
  )}
</Panel>

When to use it

Use it for

  • Sortless dashboard ledgers where rows are already curated by the product or query.
  • Cells that need composed status, trend, owner, or progress UI while retaining semantic table markup.
  • Dense financial, delivery, or people data where numeric columns must align cleanly.

Reach for something else

  • Single KPI summaries — use StatCard instead.
  • Visual trend-only cells without row comparison — use Sparkline inside StatCard instead.
  • The empty state after filtering — use EmptyState inside the same Panel frame.

Props

PropTypeDefaultDescription
columnsColumn<T>[]Column defs, left to right. Column<T> = { key, header, cell: (row, i) => ReactNode, align?, numeric?, width?, cellClassName? }.
rowsT[]Row records. Each is passed to every column's cell renderer.
numeric (per column)booleanfalseRight-aligns the column and uses tabular figures, so numbers line up.
captionReactNodeVisually-hidden <caption> describing the table, for screen readers.
densebooleanfalseTighter row padding for dense data.
stickyHeaderbooleanfalseStick the header to the top while the body scrolls.

See also