Data

Pivot table

Records summed, counted or averaged into a rows by columns table with totals, for reports and breakdowns.

Built on
<table>scope="row"scope="colgroup"position: sticky
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
3.1 kB
Dependencies
React
Sales in $k
RegionQ1Q2Total
North425193
South374481
West293362
Total108128236
Code
import { PivotTable } from "@/components/equip/pivot-table";

const sales = [
  { region: "North", quarter: "Q1", amount: 42 },
  { region: "North", quarter: "Q2", amount: 51 },
  { region: "South", quarter: "Q1", amount: 37 },
  { region: "South", quarter: "Q2", amount: 44 },
  { region: "West", quarter: "Q1", amount: 29 },
  { region: "West", quarter: "Q2", amount: 33 },
];

export default function Demo() {
  return (
    <div style={{ width: 224 }}>
      <PivotTable
        label="Sales in $k"
        hideLabel
        dense
        rows={sales}
        rowField="region"
        columnField="quarter"
        value="amount"
        rowHeader="Region"
      />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="width:224px">
  <div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Sales in $k" data-dense="true">
    <table class="eq-pivot-table">
      <caption class="eq-pivot-table-caption" data-hidden="true">Sales in $k</caption>
      <thead class="eq-pivot-table-head">
        <tr>
          <th scope="col" class="eq-pivot-table-corner">Region</th>
          <th scope="col" class="eq-pivot-table-header">Q1</th>
          <th scope="col" class="eq-pivot-table-header">Q2</th>
          <th scope="col" class="eq-pivot-table-header" data-total="">Total</th>
        </tr>
      </thead>
      <tbody class="eq-pivot-table-body">
        <tr class="eq-pivot-table-row">
          <th scope="row" class="eq-pivot-table-label">North</th>
          <td class="eq-pivot-table-cell">42</td>
          <td class="eq-pivot-table-cell">51</td>
          <td class="eq-pivot-table-cell" data-total="">
            <strong class="eq-pivot-table-figure">93</strong>
          </td>
        </tr>
        <tr class="eq-pivot-table-row">
          <th scope="row" class="eq-pivot-table-label">South</th>
          <td class="eq-pivot-table-cell">37</td>
          <td class="eq-pivot-table-cell">44</td>
          <td class="eq-pivot-table-cell" data-total="">
            <strong class="eq-pivot-table-figure">81</strong>
          </td>
        </tr>
        <tr class="eq-pivot-table-row">
          <th scope="row" class="eq-pivot-table-label">West</th>
          <td class="eq-pivot-table-cell">29</td>
          <td class="eq-pivot-table-cell">33</td>
          <td class="eq-pivot-table-cell" data-total="">
            <strong class="eq-pivot-table-figure">62</strong>
          </td>
        </tr>
      </tbody>
      <tfoot class="eq-pivot-table-foot">
        <tr class="eq-pivot-table-row">
          <th scope="row" class="eq-pivot-table-label">Total</th>
          <td class="eq-pivot-table-cell">
            <strong class="eq-pivot-table-figure">108</strong>
          </td>
          <td class="eq-pivot-table-cell">
            <strong class="eq-pivot-table-figure">128</strong>
          </td>
          <td class="eq-pivot-table-cell" data-total="">
            <strong class="eq-pivot-table-figure">236</strong>
          </td>
        </tr>
      </tfoot>
    </table>
  </div>
</div>

Examples

Revenue by region and quarter

Invoices are summed per cell, with a total column, a total row in tfoot and the grand total in the corner.

Revenue by region, 2025
RegionQuarterTotal
Q1Q2Q3Q4
Asia Pacific$95,832$103,092$100,672$107,932$407,528
Europe$145,488$141,957$152,551$163,144$603,140
Latin America$40,845$39,873$42,790$41,818$165,326
North America$176,928$190,608$204,288$199,728$771,552
Total$459,093$475,530$500,301$512,622$1,947,546
Code
import { PivotTable } from "@/components/equip/pivot-table";

type Invoice = { region: string; quarter: string; amount: number };

const regions = ["North America", "Europe", "Asia Pacific", "Latin America"];
const quarters = ["Q1", "Q2", "Q3", "Q4"];
const base = [182400, 141250, 96800, 38900];

// Two invoices per region and quarter, so each cell is a real sum.
const invoices: Invoice[] = regions.flatMap((region, r) =>
  quarters.flatMap((quarter, q) => [
    { region, quarter, amount: Math.round(base[r] * (0.42 + q * 0.035)) },
    { region, quarter, amount: Math.round(base[r] * (0.55 + ((q * 7 + r * 3) % 5) * 0.02)) },
  ]),
);

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

export default function Example() {
  return (
    <PivotTable
      label="Revenue by region, 2025"
      rows={invoices}
      rowField="region"
      columnField="quarter"
      value="amount"
      format={usd.format}
      rowHeader="Region"
      columnHeader="Quarter"
      columnOrder={quarters}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Revenue by region, 2025">
  <table class="eq-pivot-table">
    <caption class="eq-pivot-table-caption">Revenue by region, 2025</caption>
    <thead class="eq-pivot-table-head">
      <tr>
        <th scope="col" rowSpan="2" class="eq-pivot-table-corner">Region</th>
        <th scope="colgroup" colSpan="4" class="eq-pivot-table-group">Quarter</th>
        <th scope="col" rowSpan="2" class="eq-pivot-table-header" data-total="">Total</th>
      </tr>
      <tr>
        <th scope="col" class="eq-pivot-table-header">Q1</th>
        <th scope="col" class="eq-pivot-table-header">Q2</th>
        <th scope="col" class="eq-pivot-table-header">Q3</th>
        <th scope="col" class="eq-pivot-table-header">Q4</th>
      </tr>
    </thead>
    <tbody class="eq-pivot-table-body">
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Asia Pacific</th>
        <td class="eq-pivot-table-cell">$95,832</td>
        <td class="eq-pivot-table-cell">$103,092</td>
        <td class="eq-pivot-table-cell">$100,672</td>
        <td class="eq-pivot-table-cell">$107,932</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$407,528</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Europe</th>
        <td class="eq-pivot-table-cell">$145,488</td>
        <td class="eq-pivot-table-cell">$141,957</td>
        <td class="eq-pivot-table-cell">$152,551</td>
        <td class="eq-pivot-table-cell">$163,144</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$603,140</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Latin America</th>
        <td class="eq-pivot-table-cell">$40,845</td>
        <td class="eq-pivot-table-cell">$39,873</td>
        <td class="eq-pivot-table-cell">$42,790</td>
        <td class="eq-pivot-table-cell">$41,818</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$165,326</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">North America</th>
        <td class="eq-pivot-table-cell">$176,928</td>
        <td class="eq-pivot-table-cell">$190,608</td>
        <td class="eq-pivot-table-cell">$204,288</td>
        <td class="eq-pivot-table-cell">$199,728</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$771,552</strong>
        </td>
      </tr>
    </tbody>
    <tfoot class="eq-pivot-table-foot">
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Total</th>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">$459,093</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">$475,530</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">$500,301</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">$512,622</strong>
        </td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$1,947,546</strong>
        </td>
      </tr>
    </tfoot>
  </table>
</div>

Ticket count by team and priority

With aggregate count no measure is needed. columnOrder fixes the priorities in their natural order; empty cells read as No data.

Open tickets
TeamUrgentHighNormalLowTotal
Billing41126950
Integrations71518No data40
Mobile2No data12519
OnboardingNo data6311451
Total13328728160
Code
import { PivotTable } from "@/components/equip/pivot-table";

type Ticket = { team: string; priority: string };

const counts: [string, string, number][] = [
  ["Billing", "Urgent", 4],
  ["Billing", "High", 11],
  ["Billing", "Normal", 26],
  ["Billing", "Low", 9],
  ["Onboarding", "High", 6],
  ["Onboarding", "Normal", 31],
  ["Onboarding", "Low", 14],
  ["Integrations", "Urgent", 7],
  ["Integrations", "High", 15],
  ["Integrations", "Normal", 18],
  ["Mobile", "Urgent", 2],
  ["Mobile", "Normal", 12],
  ["Mobile", "Low", 5],
];

const tickets: Ticket[] = counts.flatMap(([team, priority, n]) => Array.from({ length: n }, () => ({ team, priority })));

export default function Example() {
  return (
    <PivotTable
      label="Open tickets"
      dense
      rows={tickets}
      rowField="team"
      columnField="priority"
      aggregate="count"
      rowHeader="Team"
      columnOrder={["Urgent", "High", "Normal", "Low"]}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Open tickets" data-dense="true">
  <table class="eq-pivot-table">
    <caption class="eq-pivot-table-caption">Open tickets</caption>
    <thead class="eq-pivot-table-head">
      <tr>
        <th scope="col" class="eq-pivot-table-corner">Team</th>
        <th scope="col" class="eq-pivot-table-header">Urgent</th>
        <th scope="col" class="eq-pivot-table-header">High</th>
        <th scope="col" class="eq-pivot-table-header">Normal</th>
        <th scope="col" class="eq-pivot-table-header">Low</th>
        <th scope="col" class="eq-pivot-table-header" data-total="">Total</th>
      </tr>
    </thead>
    <tbody class="eq-pivot-table-body">
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Billing</th>
        <td class="eq-pivot-table-cell">4</td>
        <td class="eq-pivot-table-cell">11</td>
        <td class="eq-pivot-table-cell">26</td>
        <td class="eq-pivot-table-cell">9</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">50</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Integrations</th>
        <td class="eq-pivot-table-cell">7</td>
        <td class="eq-pivot-table-cell">15</td>
        <td class="eq-pivot-table-cell">18</td>
        <td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">40</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Mobile</th>
        <td class="eq-pivot-table-cell">2</td>
        <td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
        <td class="eq-pivot-table-cell">12</td>
        <td class="eq-pivot-table-cell">5</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">19</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Onboarding</th>
        <td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
        <td class="eq-pivot-table-cell">6</td>
        <td class="eq-pivot-table-cell">31</td>
        <td class="eq-pivot-table-cell">14</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">51</strong>
        </td>
      </tr>
    </tbody>
    <tfoot class="eq-pivot-table-foot">
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Total</th>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">13</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">32</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">87</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">28</strong>
        </td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">160</strong>
        </td>
      </tr>
    </tfoot>
  </table>
</div>

Average order value

Overall figures are the mean of every order underneath, not the mean of the cells, so small cells do not skew them.

Average order value by channel
ChannelJanFebMarOverall
App$58.10$68.03$78.95$70.41
Retail$142.60No data$155.20$148.90
Web$102.85$86.70$132.90$102.40
Overall$101.60$77.36$111.50$96.82
Code
import { PivotTable } from "@/components/equip/pivot-table";

type Order = { channel: string; month: string; total: number };

const orders: Order[] = [
  { channel: "Web", month: "Jan", total: 84.2 },
  { channel: "Web", month: "Jan", total: 121.5 },
  { channel: "Web", month: "Feb", total: 96.0 },
  { channel: "Web", month: "Feb", total: 77.4 },
  { channel: "Web", month: "Mar", total: 132.9 },
  { channel: "App", month: "Jan", total: 58.1 },
  { channel: "App", month: "Feb", total: 64.75 },
  { channel: "App", month: "Feb", total: 71.3 },
  { channel: "App", month: "Mar", total: 69.9 },
  { channel: "App", month: "Mar", total: 88.0 },
  { channel: "Retail", month: "Jan", total: 142.6 },
  { channel: "Retail", month: "Mar", total: 155.2 },
];

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });

export default function Example() {
  return (
    <PivotTable
      label="Average order value by channel"
      rows={orders}
      rowField="channel"
      columnField="month"
      value={(o) => o.total}
      aggregate="average"
      format={usd.format}
      rowHeader="Channel"
      columnOrder={["Jan", "Feb", "Mar"]}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<div class="eq-pivot-table-scroll" tabindex="0" role="region" aria-label="Average order value by channel">
  <table class="eq-pivot-table">
    <caption class="eq-pivot-table-caption">Average order value by channel</caption>
    <thead class="eq-pivot-table-head">
      <tr>
        <th scope="col" class="eq-pivot-table-corner">Channel</th>
        <th scope="col" class="eq-pivot-table-header">Jan</th>
        <th scope="col" class="eq-pivot-table-header">Feb</th>
        <th scope="col" class="eq-pivot-table-header">Mar</th>
        <th scope="col" class="eq-pivot-table-header" data-total="">Overall</th>
      </tr>
    </thead>
    <tbody class="eq-pivot-table-body">
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">App</th>
        <td class="eq-pivot-table-cell">$58.10</td>
        <td class="eq-pivot-table-cell">$68.03</td>
        <td class="eq-pivot-table-cell">$78.95</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$70.41</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Retail</th>
        <td class="eq-pivot-table-cell">$142.60</td>
        <td class="eq-pivot-table-cell"><span aria-hidden="true">–</span><span class="eq-pivot-table-sr">No data</span></td>
        <td class="eq-pivot-table-cell">$155.20</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$148.90</strong>
        </td>
      </tr>
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Web</th>
        <td class="eq-pivot-table-cell">$102.85</td>
        <td class="eq-pivot-table-cell">$86.70</td>
        <td class="eq-pivot-table-cell">$132.90</td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$102.40</strong>
        </td>
      </tr>
    </tbody>
    <tfoot class="eq-pivot-table-foot">
      <tr class="eq-pivot-table-row">
        <th scope="row" class="eq-pivot-table-label">Overall</th>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">$101.60</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">$77.36</strong>
        </td>
        <td class="eq-pivot-table-cell">
          <strong class="eq-pivot-table-figure">$111.50</strong>
        </td>
        <td class="eq-pivot-table-cell" data-total="">
          <strong class="eq-pivot-table-figure">$96.82</strong>
        </td>
      </tr>
    </tfoot>
  </table>
</div>

Props

PropTypeDefaultWhat it does
labelstringnoneThe caption, and the name of the scroll region.
hideLabelbooleanfalseHides the caption visually; it still names the table.
rowsT[]noneThe records to summarise, one per fact, such as one per invoice.
rowFieldkeyof TnoneField whose values become the row headers.
columnFieldkeyof TnoneField whose values become the column headers.
valuekeyof T | (row: T) => numbernoneThe measure: a numeric field or a function that reads one. Not needed when aggregate is count.
aggregate"sum" | "count" | "average""sum"How the records in a cell become one figure.
format(n: number) => stringen-US number, up to 2 decimalsFormats every figure, totals included. Pass an Intl.NumberFormat's format for currency.
rowOrderstring[]noneFixes the order of the row headers. Values not listed follow, sorted with a numeric collator.
columnOrderstring[]noneFixes the order of the column headers, such as months or priorities. Listed values with no records still get a column.
totalsbooleantrueAdds a total column, a total row and the grand total.
rowHeaderReactNodenoneHeader over the row labels, such as Region.
columnHeaderReactNodenoneA header spanning the column labels, such as Quarter, in its own header row.
totalLabelstring"Total", or "Overall" for averageLabel of the total row and column.
emptyCellReactNode"–"Shown in a cell with no records. Screen readers hear No data instead.
densebooleanfalse28px rows and tighter padding.
...restComponentProps<"table">noneEvery native table attribute, on the table inside the scroll region.

Anatomy

scroll.eq-pivot-table-scrollBordered region that scrolls sideways and takes keyboard focus.
root.eq-pivot-tableThe table.
caption.eq-pivot-table-captionThe label, as the frame's title bar.
head.eq-pivot-table-head
corner.eq-pivot-table-cornerHeader over the row labels; sticks to the inline start.
group.eq-pivot-table-groupSpans the column headers when columnHeader is set.
header.eq-pivot-table-headerOne column value, or the total column with data-total.
body.eq-pivot-table-body
row.eq-pivot-table-row
label.eq-pivot-table-labelRow header with scope="row"; sticks while the figures scroll.
cell.eq-pivot-table-cellOne figure, end aligned with tabular figures.
figure.eq-pivot-table-figureA total, set in the strong weight.
foot.eq-pivot-table-footThe total row, in tfoot.
empty.eq-pivot-table-emptyShown when there are no records.
screen reader text.eq-pivot-table-srSays No data for an empty cell.

Keyboard

TabMoves focus to the scroll region.
ArrowLeftArrowRightScrolls a wide pivot sideways while the region has focus. Native browser behaviour.

Notes

  • A server component: the pivot is worked out while rendering and ships no JavaScript.
  • It is a real table with column, column group and row headers, so screen readers announce both headers for every figure.
  • Totals are worked out from the records, so an average total is the mean of every record, not the mean of the cells.
  • The row labels stick to the inline start edge, so wide pivots keep their labels in view as they scroll.
  • Pass an explicit locale to Intl in format, so the server and the browser print the same figures.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/pivot-table.tsx212 lines
import type { ComponentProps, ReactNode } from "react";
import "./pivot-table.css";

export type PivotAggregate = "sum" | "count" | "average";

export type PivotTableProps<T> = Omit<ComponentProps<"table">, "children"> & {
  /** The caption, and the name of the scroll region. */
  label: string;
  /** Hides the caption visually; it still names the table. */
  hideLabel?: boolean;
  /** The records to summarise, one per fact. */
  rows: T[];
  /** Field whose values become the row headers. */
  rowField: keyof T & string;
  /** Field whose values become the column headers. */
  columnField: keyof T & string;
  /** The measure: a numeric field, or a function that reads one. Not needed for count. */
  value?: (keyof T & string) | ((row: T) => number);
  aggregate?: PivotAggregate;
  /** Formats every figure, totals included. */
  format?: (n: number) => string;
  /** Fixes the order of the row headers. Values not listed follow, sorted. */
  rowOrder?: string[];
  /** Fixes the order of the column headers. Values not listed follow, sorted. */
  columnOrder?: string[];
  /** Adds a total column, a total row and the grand total. */
  totals?: boolean;
  /** Header over the row labels, such as "Region". */
  rowHeader?: ReactNode;
  /** Header spanning the column labels, such as "Quarter". */
  columnHeader?: ReactNode;
  /** Label of the total row and column. */
  totalLabel?: string;
  /** Shown in a cell with no records, and read as "No data". */
  emptyCell?: ReactNode;
  dense?: boolean;
};

type Acc = { sum: number; count: number };

const collator = new Intl.Collator("en-US", { numeric: true, sensitivity: "base" });
const plain = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });

function order(found: Set<string>, fixed: string[] | undefined) {
  const head = fixed ?? [];
  const rest = [...found].filter((k) => !head.includes(k)).sort(collator.compare);
  return [...head, ...rest];
}

function add(map: Map<string, Acc>, key: string, n: number) {
  const acc = map.get(key);
  if (acc) {
    acc.sum += n;
    acc.count += 1;
  } else {
    map.set(key, { sum: n, count: 1 });
  }
}

export function PivotTable<T extends object>({
  label,
  hideLabel = false,
  rows,
  rowField,
  columnField,
  value,
  aggregate = "sum",
  format = plain.format,
  rowOrder,
  columnOrder,
  totals = true,
  rowHeader,
  columnHeader,
  totalLabel = aggregate === "average" ? "Overall" : "Total",
  emptyCell = "–",
  dense = false,
  className,
  ...rest
}: PivotTableProps<T>) {
  const read = (row: T): number => {
    if (aggregate === "count" && value === undefined) return 1;
    const v = typeof value === "function" ? value(row) : value === undefined ? 0 : Number(row[value]);
    return Number.isFinite(v) ? v : 0;
  };

  // Sums and counts are kept apart, so an average total is the mean of the records, not of the cells.
  const cells = new Map<string, Acc>();
  const byRow = new Map<string, Acc>();
  const byCol = new Map<string, Acc>();
  const all: Acc = { sum: 0, count: 0 };
  const rowKeys = new Set<string>();
  const colKeys = new Set<string>();

  for (const row of rows) {
    const r = String(row[rowField] ?? "");
    const c = String(row[columnField] ?? "");
    const n = read(row);
    rowKeys.add(r);
    colKeys.add(c);
    add(cells, `${r}\u0000${c}`, n);
    add(byRow, r, n);
    add(byCol, c, n);
    all.sum += n;
    all.count += 1;
  }

  const rowList = order(rowKeys, rowOrder);
  const colList = order(colKeys, columnOrder);

  function show(acc: Acc | undefined, kind: "cell" | "total") {
    if (!acc || acc.count === 0) {
      return (
        <>
          <span aria-hidden="true">{emptyCell}</span>
          <span className="eq-pivot-table-sr">No data</span>
        </>
      );
    }
    const n = aggregate === "sum" ? acc.sum : aggregate === "count" ? acc.count : acc.sum / acc.count;
    return kind === "total" ? <strong className="eq-pivot-table-figure">{format(n)}</strong> : format(n);
  }

  const span = colList.length + (totals ? 1 : 0) + 1;

  return (
    <div className="eq-pivot-table-scroll" tabIndex={0} role="region" aria-label={label} data-dense={dense || undefined}>
      <table {...rest} className={["eq-pivot-table", className].filter(Boolean).join(" ")}>
        <caption className="eq-pivot-table-caption" data-hidden={hideLabel || undefined}>
          {label}
        </caption>
        <thead className="eq-pivot-table-head">
          {columnHeader != null && (
            <tr>
              <th scope="col" rowSpan={2} className="eq-pivot-table-corner">
                {rowHeader}
              </th>
              <th scope="colgroup" colSpan={colList.length} className="eq-pivot-table-group">
                {columnHeader}
              </th>
              {totals && (
                <th scope="col" rowSpan={2} className="eq-pivot-table-header" data-total="">
                  {totalLabel}
                </th>
              )}
            </tr>
          )}
          <tr>
            {columnHeader == null && (
              <th scope="col" className="eq-pivot-table-corner">
                {rowHeader}
              </th>
            )}
            {colList.map((c) => (
              <th key={c} scope="col" className="eq-pivot-table-header">
                {c}
              </th>
            ))}
            {totals && columnHeader == null && (
              <th scope="col" className="eq-pivot-table-header" data-total="">
                {totalLabel}
              </th>
            )}
          </tr>
        </thead>
        <tbody className="eq-pivot-table-body">
          {rowList.length === 0 ? (
            <tr>
              <td className="eq-pivot-table-empty" colSpan={span}>
                No data
              </td>
            </tr>
          ) : (
            rowList.map((r) => (
              <tr key={r} className="eq-pivot-table-row">
                <th scope="row" className="eq-pivot-table-label">
                  {r}
                </th>
                {colList.map((c) => (
                  <td key={c} className="eq-pivot-table-cell">
                    {show(cells.get(`${r}\u0000${c}`), "cell")}
                  </td>
                ))}
                {totals && (
                  <td className="eq-pivot-table-cell" data-total="">
                    {show(byRow.get(r), "total")}
                  </td>
                )}
              </tr>
            ))
          )}
        </tbody>
        {totals && rowList.length > 0 && (
          <tfoot className="eq-pivot-table-foot">
            <tr className="eq-pivot-table-row">
              <th scope="row" className="eq-pivot-table-label">
                {totalLabel}
              </th>
              {colList.map((c) => (
                <td key={c} className="eq-pivot-table-cell">
                  {show(byCol.get(c), "total")}
                </td>
              ))}
              <td className="eq-pivot-table-cell" data-total="">
                {show(all, "total")}
              </td>
            </tr>
          </tfoot>
        )}
      </table>
    </div>
  );
}
components/equip/pivot-table.css150 lines
@layer equip {
  .eq-pivot-table-scroll {
    --_row: var(--eq-h-m);
    --_pad: var(--eq-space-3);

    position: relative;
    box-sizing: border-box;
    max-width: 100%;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
  }

  .eq-pivot-table-scroll[data-dense] {
    --_row: var(--eq-h-s);
    --_pad: var(--eq-space-2);
  }

  .eq-pivot-table-scroll:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-pivot-table {
    width: 100%;
    border-collapse: separate;
    border-spacing: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
    font-variant-numeric: tabular-nums;
  }

  /* The caption sits inside the frame as the table's title bar. */
  .eq-pivot-table-caption {
    padding: var(--eq-space-2) var(--_pad);
    border-bottom: 1px solid var(--eq-line);
    font-weight: var(--eq-weight-strong);
    text-align: start;
  }

  .eq-pivot-table-caption[data-hidden],
  .eq-pivot-table-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .eq-pivot-table-corner,
  .eq-pivot-table-group,
  .eq-pivot-table-header,
  .eq-pivot-table-label,
  .eq-pivot-table-cell,
  .eq-pivot-table-empty {
    box-sizing: border-box;
    height: var(--_row);
    padding: 0 var(--_pad);
    border-bottom: 1px solid var(--eq-line);
    vertical-align: middle;
    white-space: nowrap;
  }

  .eq-pivot-table-corner,
  .eq-pivot-table-group,
  .eq-pivot-table-header {
    border-bottom-color: var(--eq-line-strong);
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
    letter-spacing: 0.01em;
  }

  .eq-pivot-table-corner {
    text-align: start;
    vertical-align: bottom;
    padding-block-end: calc((var(--_row) - 1lh) / 2);
  }

  .eq-pivot-table-group {
    border-bottom-color: var(--eq-line);
    color: var(--eq-ink);
    text-align: center;
  }

  .eq-pivot-table-header,
  .eq-pivot-table-cell {
    text-align: end;
  }

  /* Row labels stay in view while wide pivots scroll sideways. */
  .eq-pivot-table-corner,
  .eq-pivot-table-label {
    position: sticky;
    inset-inline-start: 0;
    z-index: 1;
    background: var(--eq-raised);
    border-inline-end: 1px solid var(--eq-line);
  }

  .eq-pivot-table-label {
    font-weight: var(--eq-weight);
    text-align: start;
  }

  .eq-pivot-table-cell:has(> .eq-pivot-table-sr) {
    color: var(--eq-ink-3);
  }

  :is(.eq-pivot-table-header, .eq-pivot-table-cell)[data-total] {
    border-inline-start: 1px solid var(--eq-line);
  }

  .eq-pivot-table-figure {
    font-weight: var(--eq-weight-strong);
  }

  .eq-pivot-table-body > .eq-pivot-table-row:last-child > * {
    border-bottom-color: var(--eq-line-strong);
  }

  .eq-pivot-table-foot > .eq-pivot-table-row > * {
    border-bottom: 0;
    background: var(--eq-tint);
  }

  .eq-pivot-table-foot .eq-pivot-table-label {
    /* A tint over the raised fill, so the sticky label still covers what scrolls under it. */
    background: linear-gradient(var(--eq-tint), var(--eq-tint)) var(--eq-raised);
    font-weight: var(--eq-weight-strong);
  }

  @media (hover: hover) {
    .eq-pivot-table-body > .eq-pivot-table-row:hover > .eq-pivot-table-cell {
      background: var(--eq-tint);
    }
  }

  .eq-pivot-table-empty {
    padding-block: var(--eq-space-5);
    color: var(--eq-ink-2);
    text-align: center;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index