Pivot Grid
import { PivotGrid, PivotFieldList, type PivotField } from '@absoluteui/components/pivot-grid';import '@absoluteui/components/pivot-grid/style.css';Using Angular? See Pivot Grid for Angular.
What it does
Section titled “What it does”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.
When to use
Section titled “When to use”- 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).
When not to use
Section titled “When not to use”- 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.
Examples
Section titled “Examples”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) ?? '');});Server-side aggregation with lazy groups
Section titled “Server-side aggregation with lazy groups”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;});CSV download and chart data
Section titled “CSV download and chart data”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)); });}More usage
Section titled “More usage”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.
Anatomy
Section titled “Anatomy”<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>API reference
Section titled “API reference”Factory
Section titled “Factory”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): voidGlobal configuration key: pivotGrid (see Configuration).
Options
Section titled “Options”| 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. |
Properties
Section titled “Properties”| 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. |
Methods
Section titled “Methods”| 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. |
Events
Section titled “Events”| 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. |
CSS classes
Section titled “CSS classes”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. |
Icon slots
Section titled “Icon slots”| 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 |
Design tokens
Section titled “Design tokens”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
TypeScript definitions
Section titled “TypeScript definitions”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.
Accessibility
Section titled “Accessibility”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.
Keyboard
Section titled “Keyboard”| 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 |
ARIA attributes
Section titled “ARIA attributes”| 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.
WCAG 2.2 success criteria addressed
Section titled “WCAG 2.2 success criteria addressed”Automated test results: Accessibility and compliance.
Notes and known caveats
Section titled “Notes and known caveats”- 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).
Behavior
Section titled “Behavior”States
Section titled “States”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.
State transitions
Section titled “State transitions”| 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 |
Accessibility notes
Section titled “Accessibility notes”- 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.
Responsive behavior
Section titled “Responsive behavior”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.
Right-to-left
Section titled “Right-to-left”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.
Theming
Section titled “Theming”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.
Global configuration
Section titled “Global configuration”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.
Component configuration
Section titled “Component configuration”Instance options override global and theme defaults. All options are dynamic through update(); data, dataSource, fields, layout, and expandDepth reload.
Timezones
Section titled “Timezones”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.
Security
Section titled “Security”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.
Data handling
Section titled “Data handling”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.
Performance
Section titled “Performance”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.
Supported data scale
Section titled “Supported data scale”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.
Edge cases
Section titled “Edge cases”- 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; callreload()after mutating records in place.- A value sort button cycles descending, ascending, off.
Errors
Section titled “Errors”| 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.
Known limitations
Section titled “Known limitations”- 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.
Unsupported scenarios
Section titled “Unsupported scenarios”- 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
PivotDataSourceover them). - Drill-through to underlying records.