Skip to content

Pivot Grid

DataSince 0.5.0
import { PivotGrid, PivotFieldList, type PivotField } from '@absoluteui/components/pivot-grid';
import '@absoluteui/components/pivot-grid/style.css';

Using Angular? See Pivot Grid for Angular.

A pivot table for exploring records. PivotGrid groups records by row fields and column fields and aggregates value fields (sum, count, average, minimum, maximum, distinct count). The result is a real <table> with nested column headers, indented row groups that carry their subtotal (compact form), column group subtotals, and grand totals. Users expand and collapse groups, sort members by label or by a value column, and filter any field by its members.

Data comes from a PivotDataSource. data aggregates records in the browser with the local engine; dataSource delegates aggregation to a server through the same query contract, and groups load lazily when they are expanded. Requests are cancellable and time out. The layout (fields per area, aggregates, member filters, sort, expansion) is serializable and reported with aui-layout-change.

The same subpath exports PivotFieldList (users move fields between Rows, Columns, Values, and Filters by pointer, keyboard, or single pointer, choose aggregates, and filter members), createLocalPivotSource (the engine on its own), and downloadCsv. toMatrix() hands the visible pivot to charts; toCsv() exports it.

  • Summarizing many records by two dimensions (region by quarter) with totals and drill-down.
  • Letting users explore a data set by choosing row, column, and value fields themselves (with PivotFieldList).
  • Server-side OLAP-style aggregation over data too large for the browser (dataSource, expandDepth 0).
  • Listing individual records with sorting and paging: use Data Table.
  • A single fixed summary that never changes shape: a Data Table over pre-aggregated rows is simpler.
  • Spreadsheet editing of cells or formulas.

Sales by region and quarter (local engine)

Section titled “Sales by region and quarter (local engine)”
import { PivotGrid, type PivotField } from '@absoluteui/components/pivot-grid';
interface Sale {
region: string;
city: string;
date: string;
amount: number;
}
const sales: Sale[] = [
{ region: 'North', city: 'Oslo', date: '2026-01-15', amount: 1200 },
{ region: 'North', city: 'Bergen', date: '2026-04-02', amount: 800 },
{ region: 'South', city: 'Rome', date: '2026-02-20', amount: 1500 },
];
const fields: PivotField<Sale>[] = [
{ id: 'region', label: 'Region' },
{ id: 'city', label: 'City' },
// Group dates by quarter in the accessor (the key is text such as 'Q1').
{ id: 'quarter', label: 'Quarter', value: (s) => `Q${String(Math.ceil(Number(s.date.slice(5, 7)) / 3))}` },
{ id: 'amount', label: 'Amount', numberFormat: { style: 'currency', currency: 'EUR' } },
];
const host = document.createElement('div');
document.body.append(host);
const grid = PivotGrid.create(host, {
caption: 'Sales 2026',
fields,
data: sales,
layout: { rows: ['region', 'city'], columns: ['quarter'], values: [{ field: 'amount', aggregate: 'sum' }] },
});
void grid.ready.then(() => {
host.dataset.total = String(grid.toMatrix().values.at(-1)?.at(-1) ?? '');
});
import { PivotGrid, type PivotDataSource, type PivotGridLoadErrorDetail, type PivotMembersResult, type PivotQueryResult } from '@absoluteui/components/pivot-grid';
// The server answers the query contract: one cell per requested (row path, column path) with records.
// The grid validates every answer before it renders it.
const source: PivotDataSource = {
query: async (query, signal) => {
const response = await fetch('/api/pivot/query', { method: 'POST', body: JSON.stringify(query), headers: { 'content-type': 'application/json' }, signal });
if (!response.ok) throw new Error(`Pivot query failed: ${String(response.status)}`);
return (await response.json()) as PivotQueryResult;
},
members: async (request, signal) => {
const response = await fetch('/api/pivot/members', { method: 'POST', body: JSON.stringify(request), headers: { 'content-type': 'application/json' }, signal });
if (!response.ok) throw new Error(`Pivot members failed: ${String(response.status)}`);
return (await response.json()) as PivotMembersResult;
},
};
const host = document.querySelector<HTMLElement>('#orders-pivot');
if (host) {
PivotGrid.create(host, {
caption: 'Orders',
fields: [{ id: 'country', label: 'Country' }, { id: 'customer', label: 'Customer' }, { id: 'year', label: 'Year' }, { id: 'total', label: 'Total' }],
dataSource: source,
// Only the top level loads first; each group loads when it is expanded.
expandDepth: 0,
requestTimeoutMs: 15_000,
virtual: true,
layout: { rows: ['country', 'customer'], columns: ['year'], values: [{ field: 'total', aggregate: 'sum' }] },
});
host.addEventListener('aui-load-error', (event) => {
const { request } = (event as CustomEvent<PivotGridLoadErrorDetail>).detail;
host.dataset.failed = request;
});
}

Let users build the pivot, and save the layout

Section titled “Let users build the pivot, and save the layout”
import { PivotFieldList, PivotGrid, type PivotGridLayoutChangeDetail, type PivotLayout } from '@absoluteui/components/pivot-grid';
interface Ticket {
team: string;
priority: 'low' | 'high';
status: string;
hours: number;
}
const tickets: Ticket[] = [
{ team: 'Core', priority: 'high', status: 'open', hours: 5 },
{ team: 'Web', priority: 'low', status: 'closed', hours: 2 },
];
let saved: PivotLayout | null = null;
const listHost = document.createElement('div');
const gridHost = document.createElement('div');
document.body.append(listHost, gridHost);
const grid = PivotGrid.create(gridHost, {
caption: 'Tickets',
fields: [
{ id: 'team', label: 'Team' },
{ id: 'priority', label: 'Priority', formatMember: (key) => (key === 'high' ? 'High' : 'Low') },
{ id: 'status', label: 'Status', areas: ['rows', 'columns', 'filters'] },
{ id: 'hours', label: 'Hours', aggregate: 'average' },
],
data: tickets,
layout: saved ?? { rows: ['team'], columns: ['priority'], values: [{ field: 'hours', aggregate: 'sum' }] },
});
PivotFieldList.create(listHost, { grid, label: 'Pivot fields' });
gridHost.addEventListener('aui-layout-change', (event) => {
saved = (event as CustomEvent<PivotGridLayoutChangeDetail>).detail.layout;
});
import { downloadCsv, PivotGrid } from '@absoluteui/components/pivot-grid';
import { BarChart } from '@absoluteui/components/bar-chart';
import { Button } from '@absoluteui/components/button';
const rows = [
{ product: 'A', month: '2026-01', units: 10 },
{ product: 'B', month: '2026-01', units: 4 },
{ product: 'A', month: '2026-02', units: 7 },
];
const host = document.createElement('div');
const chartHost = document.createElement('div');
document.body.append(host, chartHost);
const grid = PivotGrid.create(host, {
caption: 'Units by product',
fields: [{ id: 'product', label: 'Product' }, { id: 'month', label: 'Month' }, { id: 'units', label: 'Units' }],
data: rows,
grandTotals: 'none',
layout: { rows: ['product'], columns: ['month'], values: [{ field: 'units', aggregate: 'sum' }] },
});
void grid.ready.then(() => {
const matrix = grid.toMatrix();
BarChart.create(chartHost, {
label: 'Units by product',
categories: matrix.rows.map((r) => r.labels.join(' / ')),
series: matrix.columns.map((c, j) => ({ id: `c${String(j)}`, label: c.labels.join(' / '), values: matrix.values.map((row) => row[j] ?? null) })),
});
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Download CSV';
Button.enhance(button);
button.addEventListener('click', () => {
downloadCsv(grid.toCsv({ bom: true }), 'units.csv');
});
host.before(button);
});

Row totals only and compact cells for every pivot

Section titled “Row totals only and compact cells for every pivot”
import { configureAbsoluteUI } from '@absoluteui/core';
configureAbsoluteUI({ components: { pivotGrid: { grandTotals: 'rows', columnSubtotals: false, requestTimeoutMs: 20_000, density: 'compact' } } });
import { PivotFieldList, PivotGrid, type PivotGridLayoutChangeDetail } from '@absoluteui/components/pivot-grid';
import '@absoluteui/components/pivot-grid/style.css';
interface Sale { region: string; city: string; quarter: string; amount: number }
const sales: Sale[] = [
{ region: 'North', city: 'Oslo', quarter: 'Q1', amount: 1200 },
{ region: 'South', city: 'Rome', quarter: 'Q2', amount: 900 },
];
const gridHost = document.querySelector<HTMLElement>('#pivot');
const fieldsHost = document.querySelector<HTMLElement>('#pivot-fields');
if (gridHost && fieldsHost) {
const grid = PivotGrid.create(gridHost, {
caption: 'Sales',
fields: [{ id: 'region', label: 'Region' }, { id: 'city', label: 'City' }, { id: 'quarter', label: 'Quarter' }, { id: 'amount', label: 'Amount' }],
data: sales,
layout: { rows: ['region', 'city'], columns: ['quarter'], values: [{ field: 'amount', aggregate: 'sum' }] },
});
PivotFieldList.create(fieldsHost, { grid, label: 'Pivot fields' });
gridHost.addEventListener('aui-layout-change', (event) => {
const { layout } = (event as CustomEvent<PivotGridLayoutChangeDetail>).detail;
sessionStorage.setItem('pivot', JSON.stringify(layout));
});
}

See section 33 and metadata/pivot-grid/examples.json: local engine, server data source with lazy groups, field list with a saved layout, CSV and chart data, global configuration.

<div class="aui-pivot aui-pivot--comfortable aui-pivot--sticky [--virtual]"> (consumer host)
<div class="aui-pivot__region" role="region" tabindex="0" aria-labelledby="{caption id}">
<table class="aui-pivot__table" [aria-busy="true"] [aria-rowcount]>
<caption class="aui-pivot__caption [--hidden]">Sales</caption>
<thead class="aui-pivot__head">
<tr> <th class="aui-pivot__corner" scope="col" rowspan="..." aria-sort="ascending"><button class="aui-pivot__sort">Region / City</button>
<th class="aui-pivot__col-header" scope="colgroup" colspan="..."><button class="aui-pivot__toggle" aria-expanded="true">Q1</button>
<th class="aui-pivot__col-header aui-pivot__col-header--total" rowspan="...">Q1 total
<th class="aui-pivot__col-header aui-pivot__col-header--total">Grand total
<tr> <th class="aui-pivot__value-header" scope="col"><button class="aui-pivot__sort">Sum of Amount</button> ...
<tbody class="aui-pivot__body">
<tr class="aui-pivot__row aui-pivot__row--group">
<th class="aui-pivot__row-header" scope="row" style="--aui-pivot-level: 0"><button class="aui-pivot__toggle" aria-expanded="true">North</button>
<td class="aui-pivot__cell" headers="{column header ids} {row header id}">€180.00 ...
<tr class="aui-pivot__row">
<th class="aui-pivot__row-header" scope="row"><span class="aui-pivot__path">North, </span><span>Oslo</span>
<tr class="aui-pivot__row aui-pivot__row--total"> <th>Grand total ...
<div class="aui-pivot-fields" role="group" aria-label="{label}"> (field list host)
<div class="aui-pivot-fields__area aui-pivot-fields__area--available"> <div class="…__title">Fields</div> <ul class="…__list">
<div class="aui-pivot-fields__areas"> (Filters, Columns, Rows, Values areas, each a titled <ul>)
<li class="aui-pivot-fields__entry [--grabbed]">
<button class="aui-pivot-fields__handle" aria-pressed="false" aria-label="Move Region" aria-describedby="{instructions}">
<span class="aui-pivot-fields__label">Region</span>
<button class="aui-pivot-fields__filter" aria-expanded="false"> | <select class="aui-pivot-fields__aggregate" aria-label="Aggregate of Amount">
<button class="aui-pivot-fields__remove" aria-label="Remove Region">
[<div class="aui-pivot-fields__panel"> search, member checkboxes, Show more, Select all, Clear, Apply, Cancel]
<p class="aui-pivot-fields__instructions">To move a field, press Space or Enter on its handle...</p>
PivotGrid.create<Row>(host: HTMLElement, options: PivotGridOptions<Row>): PivotGrid<Row>; PivotFieldList.create<Row>(host: HTMLElement, options: PivotFieldListOptions<Row>): PivotFieldList; createLocalPivotSource<Row>(rows: readonly Row[], fields: readonly PivotField<Row>[], options?: { locale?: string }): LocalPivotSource<Row>; compareKeys(a: PivotKey, b: PivotKey, locale: string): number; downloadCsv(csv: string | Blob, filename: string, doc?: Document): void

Global configuration key: pivotGrid (see Configuration).

Option Type Default Description
caption string Visible <caption> and accessible name of the table and its scroll region (aria-labelledby). Non-empty. Required.
hideCaption boolean false Keep the caption for assistive technology only (visually hidden).
fields readonly PivotField<Row>[] Available fields: { id (unique; default row property), label, value?(row) (grouping or aggregation value; keys must be string | number | boolean | null, dates as ISO strings, other values converted to text), formatMember?(key) (member label), numberFormat? (Intl.NumberFormatOptions of aggregated values), areas? (allowed areas in the field list; default all), aggregate? (default when added to values; default ‘sum’) }. Changing it with update() revalidates the layout and reloads. Required.
data readonly Row[] Records aggregated in the browser by the local engine (createLocalPivotSource). Exactly one of data or dataSource. Passing a new array with update() reloads; mutating the array in place needs reload().
dataSource PivotDataSource Server-side aggregation: { query(query, signal): Promise<{ cells }>; members(request, signal): Promise<{ members, total }> }. A query lists rowParents and columnParents; the answer holds one PivotCell { row, column, values } for every combination of a requested row path (each parent plus its children one level deeper) and a requested column path that has records. Results are validated (PIVOT_DATA_SOURCE). Exactly one of data or dataSource.
layout PivotLayoutInput { rows: fieldId[]; columns: fieldId[]; values: { field, aggregate: ‘sum’ | ‘count’ | ‘average’ | ‘min’ | ‘max’ | ‘distinctCount’ }[]; filters?: fieldId[]; include?: Record<fieldId, PivotKey[]> (included members; a missing field includes all); sort?: { rows?, columns? } (PivotSort: { by: ‘label’, direction } or { by: ‘value’, value (index into values), column? (row sort: the column path compared, default [] = grand total), direction }); expanded?: { rows: PivotKey[][]; columns: PivotKey[][] } | null }. A field appears at most once in rows, columns, and filters together, and at most once in values. Serializable: save the layout getter or aui-layout-change detail.layout. Required.
virtual boolean false Render only the rows in view (plus an overscan) with spacer rows; aria-rowcount and aria-rowindex keep the positions. The region gets max-block-size 70vh unless the page sets another.
rowHeight number 40 Virtual mode row height estimate in CSS px (integer 16..200); replaced by the measured height after the first render.
emptyMessage string Body text when no record matches (default localized ‘No data’).
grandTotals 'both' | 'rows' | 'columns' | 'none' "both" Grand total row (last), grand total column (last), both, or none. Without column fields the value columns are always shown. Globally configurable.
columnSubtotals boolean true A ‘{member} total’ column after the children of each expanded column group. Row groups always show their subtotal on the group row (compact form). Globally configurable.
expandDepth number 16 Group levels expanded when the layout has no explicit expanded (integer 0..16; 0 = only the top level, the lazy-loading setting for servers). After loading, the expansion is explicit (layout.expanded). Globally configurable.
requestTimeoutMs number 30000 Timeout of every data source request in ms (integer 100..600000); the request’s signal is aborted with a TimeoutError. Globally configurable.
stickyHeader boolean true Column header rows stay visible while the region scrolls vertically (row headers always stick to the inline start). Globally configurable.
locale string "en-US" Locale of number formatting, member order (Intl.Collator, numeric), search, and messages. Globally configurable. Inherited from common configuration.
density 'compact' | 'comfortable' | 'spacious' "comfortable" Cell padding. Globally configurable. Inherited from common configuration.
direction 'ltr' | 'rtl' "ltr" rtl mirrors the collapsed-group chevron; columns follow the document direction through the table layout. Globally configurable. Inherited from common configuration.
Property Type Description
host HTMLElement The host (class aui-pivot).
table HTMLTableElement The rendered <table>.
layout PivotLayout The current layout (a serializable copy with explicit expanded paths).
fields readonly PivotField<Row>[] The fields option.
loading boolean True while a data source request runs (the table has aria-busy=true).
ready Promise<void> Resolves when the loads started by the last change finished (successfully or not).
PivotFieldList.host HTMLElement The field list host (class aui-pivot-fields, role=group).
PivotFieldList events aui-field-move: CustomEvent<PivotFieldMoveDetail> The field list dispatches aui-field-move (bubbles, not cancelable) on its own host with PivotFieldMoveDetail { field; from: ‘available’ | PivotArea; to: ‘available’ | PivotArea; index } after a move or removal was applied to the grid.
Method Description
update(options: Partial<PivotGridOptions<Row>>): void Merge and apply options (validated first). data, dataSource, fields, layout, or expandDepth reload from the data source; data and dataSource replace each other. No event.
setLayout(layout: PivotLayoutInput): void Replace the layout (validated) and reload. A changed row, column, or value list resets the expansion to expandDepth (unless the layout has expanded). Emits aui-layout-change (reason ‘layout’, or ‘filter’ when only include changed).
setFilter(field: string, include: readonly PivotKey[] | null): void Set the included members of any field (null includes all) and reload. Emits aui-layout-change (reason ‘filter’).
sort(axis: 'rows' | 'columns', sort: PivotSort | null): void Sort the members of an axis within each group by label or by a value (null: ascending labels). Blank members stay last. No reload. Emits aui-layout-change (reason ‘sort’).
setExpanded(axis: 'rows' | 'columns', path: PivotPath, expanded: boolean): Promise<void> Expand or collapse a group; the first expansion queries its children (one delta query). On failure the group stays collapsed and ‘The data could not be loaded.’ is announced. Emits aui-layout-change (reason ‘expand’ / ‘collapse’).
expandAll(axis: 'rows' | 'columns', expanded: boolean): Promise<void> Expand every group of an axis, level by level (one query per level), or collapse all. Emits aui-layout-change.
members(field: string, options?: { search?: string; offset?: number; limit?: number }): Promise<PivotMembersResult & { labels: string[] }> A page of a field’s members (filtered by the other fields’ filters) through the data source, with display labels. limit 1..10000 (default 100). Emits aui-load-error and rejects on failure.
memberLabel(field: string, key: PivotKey): string Display label: the field’s formatMember, else localized ‘(blank)’ for null, locale digits for numbers, or the text.
valueLabel(index: number): string Header of a value field, for example ‘Sum of Amount’.
reload(): Promise<void> Discard loaded cells and reload everything (keeps the expansion). The Retry button in the error state calls it.
toMatrix(): PivotMatrix The visible pivot as data: rows (path, labels, kind), columns split by value (path, labels, kind, value, valueLabel), and values[row][column] (null where empty). Feed it to charts.
toCsv(options?: PivotCsvOptions & { values?: 'formatted' | 'raw' }): string CSV (RFC 4180) of the visible pivot: one column per row field with member labels, then one per visible value column (‘Q1 / Sum of Amount’). Formula injection is escaped (escapeFormulas, default true); numbers are trusted. Options: values, delimiter, lineEnding, bom, escapeFormulas. Save it with downloadCsv.
destroy(): void Abort requests, remove the region, classes, and listeners.
update(options: Partial<Omit<PivotFieldListOptions, 'grid'>>): void Merge label, memberPageSize, locale, direction; re-render (cancels a move).
destroy(): void Remove the lists, listeners, role, and host class.
Event Detail Bubbles Cancelable Description
aui-layout-change PivotGridLayoutChangeDetail { reason: 'layout' | 'sort' | 'filter' | 'expand' | 'collapse'; layout: PivotLayout } Yes No The layout changed through the UI, the field list, or a method (not through update()). Save detail.layout. Dispatched on the grid host.
aui-load-error PivotGridLoadErrorDetail { error: unknown; request: 'query' | 'members' } Yes No A data source request failed, timed out, or returned an invalid result. A failed initial load shows ‘The data could not be loaded.’ with a Retry button. Superseded requests do not report.

All rules live in the aui.components cascade layer, so application styles override them without specificity battles.

Class Purpose
aui-pivot Grid host; aui-pivot–compact | –comfortable | –spacious, –sticky, –virtual.
aui-pivot__region Focusable, labelled scroll region (role=region, tabindex=0). Set max-block-size on it to keep headers in view.
aui-pivot__table | __caption | __head | __body The table and its parts; aui-pivot__caption–hidden hides the caption visually.
aui-pivot__corner Top-left header (row field names) with the label sort button; aria-sort.
aui-pivot__col-header | --total Column member headers (scope col/colgroup, colspan/rowspan) and subtotal/grand total headers.
aui-pivot__value-header | --total Value headers (‘Sum of Amount’) with value sort buttons; aria-sort.
aui-pivot__row | --group | --total Body rows: expanded group rows and the grand total row.
aui-pivot__row-header Row header cell (th scope=row); indentation from –aui-pivot-level (set through the CSSOM).
aui-pivot__path Visually hidden group path inside nested row headers (’North, ’).
aui-pivot__cell | --total Data cell (headers attribute lists every header id); totals are semibold.
aui-pivot__toggle | __toggle-icon Expand/collapse button (aria-expanded; aria-busy while loading).
aui-pivot__sort | __sort-label | __sort-icon Sort buttons in the corner and value headers.
aui-pivot__status | --error | __retry Loading, empty, no-values, and error states; Retry button.
aui-pivot__spacer Virtual mode spacer rows (aria-hidden).
aui-pivot-fields Field list host (role=group).
aui-pivot-fields__areas | __area | __area--available | --filters | --columns | --rows | --values | __title | __list The five labelled lists.
aui-pivot-fields__entry | --grabbed | --dragging | __label | __empty | __marker Field entries, grabbed/dragged state, empty-list text, drop marker.
aui-pivot-fields__handle | __aggregate | __filter | --active | __remove Move handle (aria-pressed), aggregate select, filter toggle (aria-expanded), remove button.
aui-pivot-fields__panel | __search | __members | __member-list | __member | __status | __more | __actions | __select-all | __clear | __apply | __cancel Member filter panel.
aui-pivot-fields__instructions | __sr Visible move instructions (aria-describedby of each handle); visually hidden text.
Slot Default icon
expand toggle chevron-right (chevron-left in RTL) / chevron-down
sort buttons arrow-up-down / arrow-up / arrow-down
field list handle / filter / remove grip / filter / close

Tokens this stylesheet reads (verified in CI). Override them in a theme or with the Theme Designer.

--aui-border-width | --aui-border-width-strong | --aui-color-border | --aui-color-border-strong | --aui-color-error-fg | --aui-color-focus-ring | --aui-color-hover | --aui-color-on-primary | --aui-color-primary | --aui-color-primary-hover | --aui-color-selected | --aui-color-selected-text | --aui-color-surface | --aui-color-surface-sunken | --aui-color-text | --aui-color-text-muted | --aui-focus-ring-offset | --aui-focus-ring-width | --aui-font-family-sans | --aui-font-size-md | --aui-font-size-sm | --aui-font-size-xs | --aui-font-weight-medium | --aui-font-weight-regular | --aui-font-weight-semibold | --aui-icon-size-sm | --aui-line-height-normal | --aui-radius-md | --aui-radius-sm | --aui-shadow-lg | --aui-space-1 | --aui-space-2 | --aui-space-3 | --aui-space-4 | --aui-space-5 | --aui-space-6 | --aui-touch-target-min

type PivotKey = string | number | boolean | null;
type PivotPath = readonly PivotKey[];
type PivotAggregate = 'sum' | 'count' | 'average' | 'min' | 'max' | 'distinctCount';
type PivotArea = 'rows' | 'columns' | 'values' | 'filters';
interface PivotField<Row> { id: string; label: string; value?: (row: Row) => unknown; formatMember?: (key: PivotKey) => string; numberFormat?: Intl.NumberFormatOptions; areas?: readonly PivotArea[]; aggregate?: PivotAggregate }
interface PivotValue { field: string; aggregate: PivotAggregate }
type PivotSort = { by: 'label'; direction: 'ascending' | 'descending' } | { by: 'value'; value: number; column?: PivotPath; direction: 'ascending' | 'descending' };
interface PivotLayout { rows: string[]; columns: string[]; values: PivotValue[]; filters: string[]; include: Record<string, PivotKey[]>; sort: { rows: PivotSort | null; columns: PivotSort | null }; expanded: { rows: PivotKey[][]; columns: PivotKey[][] } | null }
interface PivotQuery { rows: readonly string[]; columns: readonly string[]; values: readonly PivotValue[]; include: Readonly<Record<string, readonly PivotKey[]>>; rowParents: readonly PivotPath[]; columnParents: readonly PivotPath[] }
interface PivotCell { row: PivotPath; column: PivotPath; values: readonly (number | null)[] }
interface PivotDataSource {
query(query: PivotQuery, signal: AbortSignal): Promise<{ cells: readonly PivotCell[] }>;
members(request: { field: string; include: Readonly<Record<string, readonly PivotKey[]>>; search: string; offset: number; limit: number }, signal: AbortSignal): Promise<{ members: readonly { key: PivotKey; label?: string }[]; total: number }>;
}
interface PivotMatrix { rows: PivotMatrixHeader[]; columns: PivotMatrixColumn[]; values: (number | null)[][] }

The query contract: answer one cell for every combination of a requested row path and a requested column path that has records. Requested row paths are every path in rowParents plus its children one level deeper ([] is the grand total and the top level); columns likewise. Every cell holds the values in layout.values order.

Pattern: Data table with multi-level headers (WAI Tables tutorial: tables with multi-level headers, technique H43 headers/id), inside a focusable labelled scroll region. Not an ARIA grid: expand and sort buttons are native buttons in the Tab order. The field list is a group of labelled lists with toggle-button move handles (keyboard drag-and-drop pattern, as Sortable List and Dashboard Grid) and live-region announcements; member filters are disclosure panels with a native checkbox fieldset.

Key Context Action
Tab / Shift+Tab Pivot Grid Moves to the scroll region (arrow keys scroll it), then through the corner sort button, column expand buttons, value sort buttons, and row expand buttons in reading order.
Enter / Space Expand/collapse button Toggles the group; the first expansion loads its children (aria-busy). Focus stays on the button (restored after re-rendering, also in virtual mode).
Enter / Space Corner sort button Toggles the row member order between ascending and descending labels; announces ‘Sorted by {column}, …’.
Enter / Space Value sort button Sorts rows within each group by that column: descending, then ascending, then off (label order); announces the sort.
Arrow keys, Page Up/Down, Home/End Scroll region focused Scroll the table (native scrolling).
Enter / Space Field list move handle Grabs the field (aria-pressed=true) and announces ‘{field} grabbed. {area}, position {position} of {count}.’; pressed again drops it.
ArrowUp / ArrowDown Field grabbed Moves it up or down within its list; announces ‘{area}, position {position} of {count}.’
ArrowLeft / ArrowRight Field grabbed Moves it to the end of the previous or next list (Fields, Filters, Columns, Rows, Values; mirrored in RTL), skipping lists the field is not allowed in.
Home / End Field grabbed Moves it to the start or end of its list.
Escape Field grabbed Cancels: the lists return to the layout; announces ‘Move cancelled. {field} is back in {area}.’
Tab Field grabbed Drops the field and moves focus on (leaving the field list also drops).
Enter / Space Filter toggle Opens or closes the member filter panel (focus moves to its search field).
Escape Inside a filter panel Closes the panel without applying and returns focus to the filter toggle.
Element Role
scroll region <div> region (tabindex=0), named by the caption (aria-labelledby)
<table> / <caption> table (native) with its caption
corner and column headers <th> columnheader (scope=col; expanded groups scope=colgroup)
row headers <th> rowheader (scope=row)
data cells <td> cell, associated with every header through the headers attribute
expand/collapse, sort, Retry <button> button (native)
virtual spacer rows none (aria-hidden=true)
field list host group, named by aria-label
field lists <ul> / entries <li> list / listitem (native), each list named by its title (aria-labelledby)
move handle, filter toggle, remove <button> button (native); the handle is a toggle (aria-pressed), the filter toggle a disclosure (aria-expanded, aria-controls)
aggregate <select> combobox (native select)
filter panel search input (type=search), fieldset with a visually hidden legend and native checkboxes, role=status count, buttons
Attribute Element Value
aria-labelledby scroll region Caption id
headers data cells Ids of the column group headers, the member or total header, the value header, and the row header
aria-expanded expand/collapse buttons true / false
aria-busy table / expand button true while the data source request runs
aria-sort corner header / value header ascending or descending on the sorted header (the corner is ascending by default)
aria-rowcount / aria-rowindex table / header and body rows Virtual mode only: total rows (header rows included) and each rendered row’s position
visually hidden text nested row headers The group path (’North, ’), so a row header names its group even when the group row is scrolled away
aria-label move handle / aggregate select / remove / search Localized ‘Move {field}’, ‘Aggregate of {field}’, ‘Remove {field}’, ‘Search {field}’
aria-describedby move handles The visible instructions paragraph
aria-pressed move handle true while the field is grabbed (keyboard or single pointer)
live region shared live regions Assertive: grabbed, moved, dropped, cancelled, not allowed, load failed. Polite: sorted by {column}, sorting cleared.

The scroll region is a tab stop with a focus ring. Expand and sort buttons keep focus after the table re-renders (and in virtual mode the focused row stays rendered). During a keyboard or single-pointer move the grabbed field’s handle keeps focus (the lists re-render around it). Opening a filter panel focuses its search field; Apply, Cancel, and Escape return focus to the filter toggle.

Criterion Level Note
1.3.1 Info and Relationships A
1.3.2 Meaningful Sequence A
1.4.1 Use of Color A
1.4.10 Reflow AA
1.4.11 Non-text Contrast AA
2.1.1 Keyboard A
2.1.2 No Keyboard Trap A
2.4.3 Focus Order A
2.4.7 Focus Visible AA
2.5.7 Dragging Movements AA
2.5.8 Target Size (Minimum) AA
4.1.2 Name, Role, Value A
4.1.3 Status Messages AA

Automated test results: Accessibility and compliance.

  • Multi-level headers are associated with every data cell through headers/id (H43), including column groups, totals, and value headers.
  • Totals differ by weight and a strong rule, not by color alone (1.4.1).
  • Moving fields never requires dragging (2.5.7): keyboard grab and arrow keys, or click the handle and then click the target list.
  • The scroll region is keyboard scrollable and headers stick, so content reflows at 320 px without loss (1.4.10).
  • Loading and error states are text in the table body; aria-busy marks the table while loading.
  • Manual screen-reader verification (NVDA, JAWS, VoiceOver) is pending.
  • Screen readers differ in how much of a long headers list they read for each cell; very deep column nesting produces long announcements.
  • Pointer drags in the field list are not announced; keyboard and single-pointer moves are.
  • In virtual mode, table navigation commands of screen readers see only the rendered rows (aria-rowcount gives the total).

Loading (aria-busy, “Loading”), loaded, group loading (toggle aria-busy), expanded or collapsed groups, sorted by label or value, filtered, empty (“No data”), no values (“Add a field to Values to see results.”), load error with Retry, virtual. Field list: field grabbed, dragging, filter panel open.

From Trigger To
created first query resolves loaded (expanded to expandDepth, level by level)
created first query fails or times out error with Retry (aui-load-error)
error Retry / reload() loading, then loaded
collapsed group toggle / setExpanded(..., true) loading (first time), then expanded (expand)
expanded group toggle / setExpanded(..., false) collapsed (collapse); cells stay cached
loaded setLayout / setFilter / field list drop loading, then loaded (layout / filter)
loaded sort button / sort() re-ordered, no reload (sort)
field idle handle Enter/Space or click grabbed (arrows move it)
grabbed Enter/Space, Tab, click on a list dropped, layout applied
grabbed Escape cancelled
  • Nested row headers include their group path as visually hidden text (“North, Oslo”), so a row is identified even when its group row is scrolled away.
  • Totals differ by weight and a stronger rule, not by color alone.
  • Moving fields never requires dragging: keyboard grab plus arrow keys, or click the handle and then click the target list (WCAG 2.5.7).
  • Announcements: grabbed, moved, dropped, cancelled, not allowed, load failed (assertive); sort changes (polite).
  • Manual screen-reader verification (NVDA, JAWS, VoiceOver) is pending.

The table scrolls inside its region in both directions; column headers stick to the top and row headers to the inline start, so wide pivots reflow at 320 px (WCAG 1.4.10). The field list’s four areas use an auto-fit grid (minimum 12rem) and stack on narrow screens. All controls are at least touch-target-min.

The table follows the document direction; row headers sit and stick at the inline start. With direction: 'rtl' the collapsed chevron points left. In the field list ArrowLeft/ArrowRight are mirrored.

Headers and the grand total row use color-surface-sunken; totals are semibold with a border-width-strong rule. Sort icons are muted until active. Grabbed fields and active filters use color-selected with color-selected-text and a border. Forced colors: Highlight/HighlightText for grabbed and filtered states.

configureAbsoluteUI({ components: { pivotGrid: { grandTotals: 'rows', columnSubtotals: false, expandDepth: 1, requestTimeoutMs: 20_000, stickyHeader: true }, pivotFieldList: { memberPageSize: 100 } } });

Global options: grandTotals, columnSubtotals, expandDepth, requestTimeoutMs, stickyHeader, plus inherited locale, density, direction. Field list: memberPageSize (5..1000, default 50), locale, direction. Changes apply to existing instances immediately.

Instance options override global and theme defaults. All options are dynamic through update(); data, dataSource, fields, layout, and expandDepth reload.

Not applicable. Dates group by the keys the field’s value accessor returns: derive '2026-09', years, or quarters there in the time zone the application chooses. Date values without an accessor group by their ISO instant.

Expand toggle: chevron-right (chevron-left in RTL) / chevron-down. Sort: arrow-up-down, arrow-up, arrow-down. Field list: grip, filter, close. Replace them through the icon provider.

Every label, member, and value renders through textContent; there is no HTML input. Layouts and data source answers are validated before use (PIVOT_GRID_OPTIONS, PIVOT_DATA_SOURCE), so saved layouts and server responses cannot inject structure or break rendering. Styles are written through the CSSOM only (CSP-safe). CSV export escapes formula injection (=, +, -, @, tab, carriage return) unless escapeFormulas: false. Requests carry an AbortSignal and time out.

No persistence, network access, or telemetry of its own. The local engine aggregates in memory; with a dataSource, the application’s functions make every request. Values never appear in error messages. Superseded and destroyed requests are aborted and their answers ignored.

The local engine aggregates in one pass over the filtered records per query and reads only the field levels a query needs (a top-level query ignores deeper fields). Expansions are delta queries (one level under the new parent). Cells are cached per layout; collapsing never queries. Virtual mode renders the visible rows plus an overscan of 6 (at most 300). Budgets (e2e): 100,000 records to ready < 600 ms; expanding a group < 250 ms; a virtual scroll step with 5,000 rows < 50 ms.

Tested with 100,000 records locally and 5,000 visible rows in virtual mode. Keep visible value columns to about 100 (columns are not virtualized). Without virtual, more than about 1,000 visible rows slows rendering. For larger data use a server dataSource with expandDepth: 0.

  • Records whose grouping value is null, undefined, or '' group under “(blank)”, which stays last in both sort directions.
  • Values that are not finite numbers are ignored by sum, average, min, and max; count and distinct count count every non-empty value.
  • An empty layout (no values) shows “Add a field to Values to see results.”; no row fields shows a single “Grand total” row; no column fields shows one column per value.
  • Filters that remove every member of an expanded group drop that group from the expansion.
  • Sorting by value compares the chosen column (default the grand total column); blank cells sort last.
  • update({ data }) with the same array instance does not reload; call reload() after mutating records in place.
  • A value sort button cycles descending, ascending, off.
Code Category Cause
PIVOT_GRID_OPTIONS configuration Empty caption; invalid or duplicate fields; both or neither of data and dataSource; invalid layout (unknown field, a field used twice, unknown aggregate, invalid sort or expanded paths); invalid rowHeight, members() limits.
PIVOT_DATA_SOURCE configuration A data source answer with the wrong shape or non-finite values (reported through aui-load-error).
PIVOT_FIELD_LIST_OPTIONS configuration Field list without a Pivot Grid, empty label, invalid memberPageSize.
DATA_TABLE_CSV configuration Invalid toCsv() options or an empty downloadCsv() file name (shared CSV writer).

A failed construction leaves the host untouched. Rejected or timed-out requests show the error state (initial load) or keep the group collapsed (expansion) and emit aui-load-error.

  • Compact form only (subtotals on group rows); no tabular or outline forms.
  • Columns are not virtualized.
  • A query returns every child of a requested group (no paging of group children).
  • Six built-in aggregates; no calculated fields or percentage-of-total displays.
  • Member filters are include lists (no label or value conditions such as “top 10”).
  • Cells are not individually focusable (not an ARIA grid).
  • Manual screen-reader verification is pending.
  • Editing values or write-back.
  • Built-in pivot charts (use toMatrix() with Bar Chart or Line Chart).
  • MDX/XMLA or other OLAP protocols (implement a PivotDataSource over them).
  • Drill-through to underlying records.