Skip to content

Data Table

Data displaySince 0.2.0
import { DataTable } from '@absoluteui/components/data-table';
import '@absoluteui/components/pagination/style.css';

Using Angular? See Data Table for Angular.

Show records that users scan, compare, sort, filter, select, and page through: an enterprise CRUD table with grid features. DataTable renders a real <table> with a <caption>, column headers (th scope="col"), and a row header per row (th scope="row"). Cells can be typed (number, currency, date, datetime in the configured timezone, boolean) and are formatted for the locale. Sorting is stable, by one column or (since 0.3.0) several. Selection uses native radios (single) or checkboxes (multiple, with a per-page “Select all rows” and Shift+click ranges). Paging composes Pagination with a result summary. In client mode the table filters, sorts, and pages rows itself. In server mode rows is the current page, and sorting, filtering, and paging only emit events.

Since 0.3.0, every one of these is opt-in and off by default, so a table without them renders exactly as before:

Feature Enable with
Multi-column sorting (Shift+click, Shift+Enter) multiSort: true, or a sort array
Column filters (text, number, date, enum) and a server filtering contract column filter (+ filterOptions), filters, aui-filter-change
Global search searchable: true, search
Column visibility and column chooser hiddenColumns, DataTableColumnChooser.create(button, table)
Column resize (pointer and keyboard) resizable: true, columnWidths, column minWidth/maxWidth
Column pinning (sticky start/end) pinnedColumns: { start, end }
Column order columnOrder
Row expansion (detail rows) renderDetail, isRowExpandable, expanded
Virtual scrolling virtual: true
CSV export toCsv(), toCsvBlob(), downloadCsv()
State serialization getState(), setState()

It is deliberately not an ARIA grid. Users Tab through the search field, sort buttons, resize handles, filter fields, selection inputs, disclosure buttons, and interactive cell content. Arrow-key cell navigation, editing, and cell selection are not provided.

Design decision (0.3.0): the grid features extend Data Table instead of adding a separate Data Grid subpath. The options, events, and markup of 0.2.0 are unchanged (the sort event gained a sorts field), there is one component to learn, and server/client modes work the same for every feature. The column chooser is a separate class, so a table without it does not bundle Menu.

  • Lists of records users scan, compare, sort, select, and page through (orders, users, invoices).
  • Server-paged data where the backend sorts, filters, and pages, and the table renders the current page.
  • Operational grids that need filters, several sort columns, column choice and widths, pinned identifier columns, detail rows, or CSV export, with the view state saved and restored.
  • Large client-side row sets (tested with 100,000 rows) in a fixed-height region with virtual: true.
  • Spreadsheet-like interaction with cell-by-cell arrow-key navigation or cell selection: not provided (the table keeps plain table semantics; every control, editable cells included, is reached with Tab).
  • Page layout or visual alignment of non-tabular content: use Layout.
  • Rows of different heights (wrapping text, detail rows) with virtual scrolling: virtual rows are single-line and uniform; page instead.
  • Very large data sets that do not fit in memory: use server mode.

Client-side sorting, paging, and multiple selection

Section titled “Client-side sorting, paging, and multiple selection”
import { DataTable, type DataTableColumn, type DataTableSelectionChangeDetail } from '@absoluteui/components/data-table';
import { instant, plainDate, type Instant, type PlainDate } from '@absoluteui/core';
interface Order {
id: string;
customer: string;
total: number;
placed: PlainDate;
updated: Instant;
paid: boolean;
}
const orders: Order[] = [
{ id: 'o-1001', customer: 'Acme Corp', total: 1250.5, placed: plainDate(2024, 3, 1), updated: instant(Date.UTC(2024, 2, 2, 9, 30)), paid: true },
{ id: 'o-1002', customer: 'Globex', total: 310, placed: plainDate(2024, 3, 4), updated: instant(Date.UTC(2024, 2, 4, 16, 0)), paid: false },
];
const columns: DataTableColumn<Order>[] = [
{ id: 'customer', header: 'Customer', accessor: (o) => o.customer },
{ id: 'total', header: 'Total', accessor: (o) => o.total, type: 'currency', currency: 'USD' },
{ id: 'placed', header: 'Placed', accessor: (o) => o.placed, type: 'date' },
{ id: 'updated', header: 'Updated', accessor: (o) => o.updated, type: 'datetime' },
{ id: 'paid', header: 'Paid', accessor: (o) => o.paid, type: 'boolean', sortable: false },
];
const host = document.createElement('div');
document.body.append(host);
const table = DataTable.create(host, {
caption: 'Orders',
columns,
rows: orders,
rowId: (o) => o.id,
selection: 'multiple',
pageSize: 25,
pageSizes: [25, 50, 100],
timezone: 'America/New_York',
sort: { column: 'placed', direction: 'descending' },
});
host.addEventListener('aui-selection-change', (event) => {
const { selected } = (event as CustomEvent<DataTableSelectionChangeDetail>).detail;
host.dataset.selectedCount = String(selected.length);
});
table.setSelected(['o-1001']);

Server mode: load pages and sorted data on events

Section titled “Server mode: load pages and sorted data on events”
import { DataTable, type DataTablePageChangeDetail, type DataTableSort, type DataTableSortChangeDetail } from '@absoluteui/components/data-table';
interface User {
id: string;
name: string;
email: string;
}
const host = document.createElement('div');
document.body.append(host);
const table = DataTable.create<User>(host, {
caption: 'Users',
mode: 'server',
total: 0,
rows: [],
loading: true,
rowId: (u) => u.id,
columns: [
{ id: 'name', header: 'Name', accessor: (u) => u.name },
{ id: 'email', header: 'Email', accessor: (u) => u.email },
],
});
let sort: DataTableSort | null = null;
const load = async (page: number, pageSize: number): Promise<void> => {
table.update({ loading: true });
const query = new URLSearchParams({ page: String(page), size: String(pageSize), sort: sort ? `${sort.column}:${sort.direction}` : '' });
const response = await fetch(`/api/users?${query.toString()}`);
const body = (await response.json()) as { rows: User[]; total: number };
table.update({ rows: body.rows, total: body.total, loading: false });
};
host.addEventListener('aui-sort-change', (event) => {
sort = (event as CustomEvent<DataTableSortChangeDetail>).detail.sort;
void load(table.page, table.pageSize ?? 25);
});
host.addEventListener('aui-page-change', (event) => {
const { page, pageSize } = (event as CustomEvent<DataTablePageChangeDetail>).detail;
void load(page, pageSize);
});
void load(1, 25);

Custom cell rendering and a row-header column

Section titled “Custom cell rendering and a row-header column”
import { DataTable } from '@absoluteui/components/data-table';
interface Invoice {
id: string;
number: string;
amount: number;
}
const host = document.createElement('div');
document.body.append(host);
DataTable.create<Invoice>(host, {
caption: 'Invoices',
hideCaption: true,
pageSize: null,
density: 'compact',
rows: [{ id: 'i1', number: 'INV-001', amount: 99.5 }],
rowId: (i) => i.id,
columns: [
{ id: 'number', header: 'Invoice', accessor: (i) => i.number, rowHeader: true, width: '12rem' },
{ id: 'amount', header: 'Amount', accessor: (i) => i.amount, type: 'number', format: (value) => `${String(value)} EUR` },
{
id: 'open',
header: 'Actions',
accessor: () => null,
sortable: false,
render: (invoice, doc) => {
const link = doc.createElement('a');
link.href = `/invoices/${encodeURIComponent(invoice.id)}`;
link.textContent = `Open ${invoice.number}`;
return link;
},
},
],
});

Since 0.3.0: filters, multi-sort, column chooser, resize, pinning, row details, CSV export, and saved view state

Section titled “Since 0.3.0: filters, multi-sort, column chooser, resize, pinning, row details, CSV export, and saved view state”
import {
DataTable,
DataTableColumnChooser,
downloadCsv,
type DataTableColumn,
type DataTableState,
} from '@absoluteui/components/data-table';
import { instant, plainDate, type Instant, type PlainDate } from '@absoluteui/core';
import { Button } from '@absoluteui/components/button';
interface Ticket {
id: string;
title: string;
status: 'open' | 'closed';
priority: number;
due: PlainDate;
updated: Instant;
description: string;
}
const tickets: Ticket[] = [
{ id: 'T-1', title: 'Login fails on Safari', status: 'open', priority: 1, due: plainDate(2026, 4, 1), updated: instant(Date.UTC(2026, 2, 20, 9, 0)), description: 'Steps: open /login in Safari 17.' },
{ id: 'T-2', title: 'Export button misaligned', status: 'closed', priority: 3, due: plainDate(2026, 3, 15), updated: instant(Date.UTC(2026, 2, 18, 16, 30)), description: 'Seen at 320 px.' },
];
const columns: DataTableColumn<Ticket>[] = [
{ id: 'id', header: 'Ticket', accessor: (t) => t.id, rowHeader: true, hideable: false },
{ id: 'title', header: 'Title', accessor: (t) => t.title, filter: 'text' },
{ id: 'status', header: 'Status', accessor: (t) => t.status, filter: 'enum', filterOptions: [{ value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' }] },
{ id: 'priority', header: 'Priority', accessor: (t) => t.priority, type: 'number', filter: 'number', minWidth: 80 },
{ id: 'due', header: 'Due', accessor: (t) => t.due, type: 'date', filter: 'date' },
{ id: 'updated', header: 'Updated', accessor: (t) => t.updated, type: 'datetime', filter: 'date' },
];
const toolbar = document.createElement('div');
const chooserButton = document.createElement('button');
chooserButton.type = 'button';
chooserButton.textContent = 'Columns';
Button.enhance(chooserButton);
const exportButton = document.createElement('button');
exportButton.type = 'button';
exportButton.textContent = 'Export CSV';
Button.enhance(exportButton);
toolbar.append(chooserButton, exportButton);
const host = document.createElement('div');
document.body.append(toolbar, host);
const STORAGE_KEY = 'tickets-table';
const table = DataTable.create(host, {
caption: 'Tickets',
columns,
rows: tickets,
rowId: (t) => t.id,
selection: 'multiple',
multiSort: true,
searchable: true,
resizable: true,
pinnedColumns: { start: ['id'] },
timezone: 'America/New_York',
sort: [{ column: 'status', direction: 'descending' }, { column: 'priority', direction: 'ascending' }],
renderDetail: (ticket, doc) => {
const p = doc.createElement('p');
p.textContent = ticket.description;
return p;
},
});
DataTableColumnChooser.create(chooserButton, table);
// Restore and save the view state (the table never persists anything itself).
const saved = localStorage.getItem(STORAGE_KEY);
if (saved !== null) {
try {
table.setState(JSON.parse(saved) as DataTableState);
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
for (const type of ['aui-sort-change', 'aui-filter-change', 'aui-columns-change', 'aui-page-change']) {
host.addEventListener(type, () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(table.getState()));
});
}
exportButton.addEventListener('click', () => {
const rows = table.selected.length > 0 ? 'selected' : 'filtered';
downloadCsv(table.toCsvBlob({ rows, bom: true }), 'tickets.csv');
});

Since 0.3.0: server mode with filters, search, and multi-sort as one query

Section titled “Since 0.3.0: server mode with filters, search, and multi-sort as one query”
import { DataTable, type DataTableColumn, type DataTableState } from '@absoluteui/components/data-table';
interface Customer {
id: string;
name: string;
country: string;
revenue: number;
}
const columns: DataTableColumn<Customer>[] = [
{ id: 'name', header: 'Name', accessor: (c) => c.name, filter: 'text' },
{ id: 'country', header: 'Country', accessor: (c) => c.country, filter: 'enum', filterOptions: [{ value: 'DE', label: 'Germany' }, { value: 'IN', label: 'India' }] },
{ id: 'revenue', header: 'Revenue', accessor: (c) => c.revenue, type: 'currency', currency: 'EUR', filter: 'number' },
];
const host = document.createElement('div');
document.body.append(host);
const table = DataTable.create<Customer>(host, {
caption: 'Customers',
mode: 'server',
total: 0,
rows: [],
loading: true,
rowId: (c) => c.id,
columns,
multiSort: true,
searchable: true,
});
// One query shape for every user change: sort, filters, search, and page all come from getState().
let controller: AbortController | null = null;
const load = async (state: DataTableState): Promise<void> => {
controller?.abort();
controller = new AbortController();
table.update({ loading: true });
const response = await fetch('/api/customers/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sort: state.sort, filters: state.filters, search: state.search, page: state.page, pageSize: state.pageSize }),
signal: controller.signal,
});
const body = (await response.json()) as { rows: Customer[]; total: number };
table.update({ rows: body.rows, total: body.total, loading: false });
};
for (const type of ['aui-sort-change', 'aui-filter-change', 'aui-page-change']) {
host.addEventListener(type, () => {
load(table.getState()).catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) table.update({ loading: false });
});
});
}
void load(table.getState());

Since 0.3.0: virtual scrolling of 100,000 client rows

Section titled “Since 0.3.0: virtual scrolling of 100,000 client rows”
import { DataTable } from '@absoluteui/components/data-table';
interface Reading {
id: string;
sensor: string;
value: number;
}
const readings: Reading[] = Array.from({ length: 100_000 }, (_, i) => ({ id: `r${String(i)}`, sensor: `Sensor ${String(i % 250)}`, value: (i * 37) % 1000 }));
const host = document.createElement('div');
document.body.append(host);
// Only the rows in view (plus an overscan) are in the DOM; the region is 70vh tall by default.
DataTable.create<Reading>(host, {
caption: 'Sensor readings',
rows: readings,
rowId: (r) => r.id,
pageSize: null,
virtual: true,
selection: 'multiple',
columns: [
{ id: 'sensor', header: 'Sensor', accessor: (r) => r.sensor, filter: 'text' },
{ id: 'value', header: 'Value', accessor: (r) => r.value, type: 'number', filter: 'number' },
],
});
import { DataTable, DataTableColumnChooser, downloadCsv, type DataTableSelectionChangeDetail } from '@absoluteui/components/data-table';
import '@absoluteui/components/pagination/style.css';
import '@absoluteui/components/menu/style.css';
import '@absoluteui/components/data-table/style.css';
interface Invoice { id: string; customer: string; amount: number; overdue: boolean }
const host = document.querySelector<HTMLElement>('#invoices');
const deleteButton = document.querySelector<HTMLButtonElement>('#delete-selected');
const columnsButton = document.querySelector<HTMLButtonElement>('#columns');
const exportButton = document.querySelector<HTMLButtonElement>('#export');
if (host && deleteButton && columnsButton && exportButton) {
let invoices: Invoice[] = [
{ id: 'i-1', customer: 'Acme Corp', amount: 1250.5, overdue: false },
{ id: 'i-2', customer: 'Globex', amount: 310, overdue: true },
];
const table = DataTable.create<Invoice>(host, {
caption: 'Invoices',
selection: 'multiple',
searchable: true,
rows: invoices,
rowId: (i) => i.id,
columns: [
{ id: 'customer', header: 'Customer', accessor: (i) => i.customer, filter: 'text' },
{ id: 'amount', header: 'Amount', accessor: (i) => i.amount, type: 'currency', currency: 'USD', filter: 'number' },
{ id: 'overdue', header: 'Overdue', accessor: (i) => i.overdue, type: 'boolean', filter: 'enum' },
],
});
DataTableColumnChooser.create(columnsButton, table);
exportButton.addEventListener('click', () => {
downloadCsv(table.toCsv({ rows: 'filtered' }), 'invoices.csv');
});
host.addEventListener('aui-selection-change', (event) => {
deleteButton.disabled = (event as CustomEvent<DataTableSelectionChangeDetail>).detail.selected.length === 0;
});
deleteButton.addEventListener('click', () => {
const doomed = new Set(table.getSelected());
invoices = invoices.filter((i) => !doomed.has(i.id));
table.setRows(invoices);
deleteButton.disabled = true;
});
}
import { DataTable } from '@absoluteui/components/data-table';
const host = document.createElement('div');
document.body.append(host);
DataTable.create(host, {
caption: 'Users',
rows: [{ id: 'u1', name: 'Ada', logins: 42 }],
rowId: (u) => u.id,
multiSort: true,
searchable: true,
columns: [
{ id: 'name', header: 'Name', accessor: (u) => u.name, filter: 'text' },
{ id: 'logins', header: 'Logins', accessor: (u) => u.logins, type: 'number', filter: 'number' },
],
});
<div class="aui-data-table aui-data-table--comfortable aui-data-table--sticky [--virtual] [--fixed] [--pinned]"> (consumer host)
<div class="aui-data-table__toolbar" role="search" aria-label="Orders"> (searchable only)
<label class="aui-data-table__search"><span class="aui-data-table__search-label">Search</span><input type="search" class="aui-data-table__search-input aui-data-table__control"></label>
<div class="aui-data-table__body">
<div class="aui-data-table__region" role="region" tabindex="0" aria-labelledby="{caption id}" [aria-busy="true"]>
<table class="aui-data-table__table" [aria-rowcount]> (aria-rowcount in virtual mode)
<caption class="aui-data-table__caption" id>Orders</caption>
<thead class="aui-data-table__head"> (sticky as a whole)
<tr class="aui-data-table__header-row">
<th scope="col" class="aui-data-table__header-cell aui-data-table__select-cell"> (selection)
<span class="aui-data-table__box"><input type="checkbox" class="aui-data-table__check aui-data-table__select-all" aria-label="Select all rows">...marks</span>
<th scope="col" class="aui-data-table__header-cell aui-data-table__expand-cell"><span class="…visually-hidden">Details</span> (renderDetail)
<th scope="col" class="aui-data-table__header-cell [aui-data-table__cell--pinned-start]" data-column="customer" aria-sort="ascending" [aria-label="Customer"]>
<button type="button" class="aui-data-table__sort" [aria-describedby="{hint}"]>
<span class="aui-data-table__sort-label">Customer</span><span class="…visually-hidden">sort priority 1, ascending</span>
<span class="aui-data-table__sort-priority" aria-hidden="true">1</span><svg class="aui-data-table__sort-icon" aria-hidden="true">
<span class="aui-data-table__resizer" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize Customer" aria-valuenow> (resizable)
<th scope="col" class="aui-data-table__header-cell">Paid</th> (sortable: false)
<tr class="aui-data-table__filter-row"> (columns with a filter)
<td class="aui-data-table__filter-cell"><input type="search" class="aui-data-table__filter-input aui-data-table__control" aria-label="Filter Customer">
<td class="aui-data-table__filter-cell"><span class="aui-data-table__filter-range"><input type="number" aria-label="Minimum Amount"><input type="number" aria-label="Maximum Amount">
<td class="aui-data-table__filter-cell"><select class="aui-data-table__filter-select" aria-label="Filter Paid">
<tbody class="aui-data-table__rows">
<tr class="aui-data-table__spacer" aria-hidden="true"><td colspan> (virtual mode)
<tr class="aui-data-table__row [aui-data-table__row--selected]" [aria-rowindex]>
<td class="aui-data-table__cell aui-data-table__select-cell"><span class="aui-data-table__box"><input type="checkbox" aria-label="Select row Acme Corp">...</span></td>
<td class="aui-data-table__cell aui-data-table__expand-cell"><button class="aui-data-table__expander" aria-expanded="true" aria-controls="{detail id}" aria-label="Details for Acme Corp"><svg aria-hidden="true">
<th scope="row" class="aui-data-table__cell">Acme Corp</th>
<td class="aui-data-table__cell aui-data-table__cell--end">$1,250.50</td>
<tr class="aui-data-table__detail-row" id="{detail id}"><td class="aui-data-table__detail" colspan>{renderDetail content}
<tr class="aui-data-table__empty-row"><td class="aui-data-table__empty" colspan>No results (no rows)
<tr class="aui-data-table__row aui-data-table__skeleton-row" aria-hidden="true">... (loading, no rows)
<div class="aui-data-table__status [aui-data-table__status--overlay]" role="status"> (spinner + "Loading")
<span id="{hint}" hidden>Hold Shift while activating to sort by more than one column.</span>
<div class="aui-data-table__pagination"> (Pagination host; hidden when pageSize is null)

The column chooser attaches to a <button> you render (for example next to your own export button): Menu adds aria-haspopup="menu", aria-expanded, and a role="menu" popup with menuitemcheckbox items after the button.

DataTable.create<Row>(host: HTMLElement, options: DataTableOptions<Row>): DataTable<Row>; DataTableColumnChooser.create<Row>(trigger: HTMLButtonElement, table: DataTable<Row>, options?: DataTableColumnChooserOptions): DataTableColumnChooser<Row>; downloadCsv(csv: string | Blob, filename: string, doc?: Document): void

Global configuration key: dataTable (see Configuration).

Option Type Default Description
caption string Table caption; the accessible name of the table and its scroll region. Required.
hideCaption boolean false Visually hide the caption (it stays the accessible name).
columns readonly DataTableColumn<Row>[] Column definitions: id, header, accessor, and optional type, currency, format, render, sortable, compare, align, width, rowHeader; since 0.3.0 also filter, filterOptions, searchable, hideable, resizable, minWidth, maxWidth, exportable, exportValue; since 0.9.0 also aggregate, renderHeader, editable, editor, editOptions, validate, renderEditor. Required.
rows readonly Row[] All rows (client mode) or the current page (server mode). Required.
rowId (row: Row) => string Stable, unique, non-empty id per row. Duplicates throw DATA_TABLE_ROW_ID. Required.
sort DataTableSort | readonly DataTableSort[] | null null Current sort; each column must be sortable and appear once. An array (since 0.3.0) sorts by several columns, first entry first.
selected readonly string[] [] Selected row ids.
isRowSelectable (row: Row) => boolean Rows returning false get a disabled selection input and are skipped by select all and range selection.
pageSizes readonly number[] Render a ‘Rows per page’ select in the composed Pagination.
page number 1 Current page, 1-based; clamped to the page count.
mode 'client' | 'server' "client" client filters, sorts, and pages rows locally; server renders rows as the current page and only emits events.
total number Server mode: total rows across all pages (required when paged).
loading boolean false aria-busy on the region, an announced ‘Loading’ status, skeleton rows when empty, otherwise an overlay over existing rows.
emptyMessage string "No results (localized)" Text shown when there are no rows (also when filters match nothing).
filters Readonly<Record<string, DataTableFilter>> {} Since 0.3.0. Active column filters by column id; each filter type must match the column’s filter kind (text, number, date, enum). JSON-safe.
search string "" Since 0.3.0. Global search text: every whitespace-separated term must appear in the displayed text of some visible, searchable column (case-insensitive).
searchable boolean false Since 0.3.0. Render a labelled search field in a role=search toolbar above the table.
filterRow boolean true Since 0.3.0. Render the filter row when a visible column has a filter; false keeps filters programmatic.
hiddenColumns readonly string[] [] Since 0.3.0. Hidden column ids; at least one column stays visible.
columnOrder readonly string[] [] Since 0.3.0. Display order by column id; unlisted columns follow in definition order.
columnWidths Readonly<Record<string, number>> {} Since 0.3.0. Border-box column widths in CSS pixels, clamped to each column’s minWidth/maxWidth. Any entry switches the table to a fixed layout.
pinnedColumns { start?: readonly string[]; end?: readonly string[] } {} Since 0.3.0. Sticky columns at the inline start and end edges, outermost first (logical edges, so right-to-left layouts mirror). Pinned columns move to their edge; start pins also pin the selection and disclosure columns.
resizable boolean false Since 0.3.0. Resize handles (focusable separators) on column headers for pointer drag and arrow keys; a column’s resizable overrides it.
renderDetail (row: Row, doc: Document) => Node | string Since 0.3.0. Row detail content; enables a disclosure button per row. Called when a row is first expanded; returned Nodes are consumer-owned, strings are text. Not combinable with virtual.
isRowExpandable (row: Row) => boolean Since 0.3.0. Rows returning false get no disclosure button.
expanded readonly string[] [] Since 0.3.0. Expanded row ids (pruned like the selection).
virtual boolean false Since 0.3.0. Windowed rendering of the current page inside the scroll region (single-line rows of uniform height); aria-rowcount and aria-rowindex describe the full set.
rowHeight number 40 Since 0.3.0. Virtual mode: initial row height estimate in CSS pixels (16 to 500), replaced by the measured height.
groupBy readonly string[] Group rows by these column ids, nested in order (since 0.9.0). Client mode only; not with virtual.
collapsedGroups readonly string[] Collapsed group keys; groups are expanded by default (since 0.9.0).
renderGroupHeader (group: DataTableGroup<Row>, doc: Document) => Node Custom group header content (since 0.9.0); decorative, released with releaseNode when dropped.
showTotals boolean "true" Show the totals row when a column aggregates or totals is set (since 0.9.0).
totals Readonly<Record<string, unknown>> Server mode: totals by column id, computed by the server (since 0.9.0).
renderEmpty (doc: Document) => Node Custom empty-state content (since 0.9.0); replaces emptyMessage, released with releaseNode when dropped.
icons Partial<{ sortAscending: IconRef; sortDescending: IconRef; sortNone: IconRef; checked: IconRef; indeterminate: IconRef; loading: IconRef; expand: IconRef; collapse: IconRef }> Icon slot overrides (expand and collapse since 0.3.0).
selection 'none' | 'single' | 'multiple' "none" Row selection: radios (single) or checkboxes with select all (multiple). Globally configurable.
pageSize number | null 25 Rows per page (1 to 1,000,000); null renders all rows without pagination. Globally configurable.
stickyHeader boolean true Keep the header rows (and the filter row) visible while the scroll region scrolls. Globally configurable.
sortCycle 'tri-state' | 'bi-state' "tri-state" tri-state: ascending, descending, unsorted. bi-state: ascending, descending. Globally configurable.
multiSort boolean false Since 0.3.0. Shift+click or Shift+Enter/Space on a sort button adds the column to the sort (or cycles it in place). Globally configurable.
filterDelay number 250 Since 0.3.0. Milliseconds (0 to 5000) between the last keystroke in a text or number filter or the search field and filtering; Enter, blur, and select/date changes apply immediately. Globally configurable.
locale string "en-US" Messages, number/date formatting, text collation, and case-insensitive matching. Globally configurable. Inherited from common configuration.
timezone string "UTC" IANA timezone for datetime columns: formatting and date filters. Globally configurable. Inherited from common configuration.
density 'compact' | 'comfortable' | 'spacious' "comfortable" Cell padding. Globally configurable. Inherited from common configuration.
direction 'ltr' | 'rtl' "ltr" Passed to the composed Pagination (mirrors its chevrons). Globally configurable. Inherited from common configuration.
Property Type Description
host HTMLElement Consumer host; its children are owned by the component.
table HTMLTableElement The <table> element.
sort DataTableSort | null The primary sort (a copy).
sorts DataTableSort[] Since 0.3.0. All sorts by priority (copies).
filters Record<string, DataTableFilter> Since 0.3.0. Active filters by column id (a copy).
search string Since 0.3.0. Global search text.
expanded string[] Since 0.3.0. Expanded row ids (a copy).
collapsedGroups string[] Since 0.9.0. Keys of collapsed groups (a copy).
rowCount number Since 0.3.0. Rows matching the filters and search across all pages (client mode), or total (server mode).
page number Current page (1-based).
pageSize number | null Rows per page, or null when paging is off.
selected string[] Selected row ids in selection order (a copy).
options Readonly<DataTableOptions<Row>> Current instance options.
trigger | table | menu HTMLButtonElement | DataTable<Row> | Menu Since 0.3.0. DataTableColumnChooser: its trigger button, the table it controls, and the composed Menu.
Method Description
update(options: Partial<DataTableOptions<Row>>): void Merge and apply options. Row elements are reused for unchanged rows; selection keeps ids that are still present (client mode). Emits nothing.
setRows(rows: readonly Row[], total?: number): void Replace rows (and the server-mode total).
getSelected(): string[] Selected row ids.
setSelected(ids: readonly string[]): void Replace the selection without events. Unknown (client mode) and unselectable ids are dropped.
clearSelection(): void Clear the selection without events.
getState(): DataTableState Since 0.3.0. JSON-safe snapshot: { version: 1, sort, filters, search, columns: { order, hidden, widths, pinned }, page, pageSize, collapsedGroups (since 0.9.0) }.
setState(state: DataTableStateInput): void Since 0.3.0. Restore a snapshot or any subset of it (omitted parts are unchanged). Validates untrusted input (DATA_TABLE_STATE on malformed structure, state unchanged); unknown column ids and values a column cannot take are dropped. Emits nothing.
toCsv(options?: DataTableCsvOptions): string Since 0.3.0. RFC 4180 CSV of visible/all/named columns and filtered/page/selected/all rows, formatted or raw values, with formula-injection protection. No side effects.
toCsvBlob(options?: DataTableCsvOptions): Blob Since 0.3.0. toCsv() as a text/csv;charset=utf-8 Blob.
destroy(): void Remove rendering, listeners, timers, observers, the composed Pagination, and subscriptions.
refresh(): void Since 0.3.0. Rebuild the chooser items from the table’s columns and visibility (also done on open and after user changes).
destroy(): void Since 0.3.0. Remove the chooser’s listeners and its Menu (the trigger button stays).
downloadCsv(csv: string | Blob, filename: string, doc?: Document): void Since 0.3.0. Save CSV through a temporary object URL and an <a download> click. The only download side effect; nothing calls it implicitly.
Event Detail Bubbles Cancelable Description
aui-sort-change { sort: DataTableSort | null; sorts: DataTableSort[] } Yes No The user activated a sort button (sorts since 0.3.0). Informative in client mode; in server mode load sorted rows. The page returns to 1.
aui-selection-change { selected: string[] } Yes No The user changed the selection (row input, select all, or Shift+click range).
aui-page-change { page: number; pageSize: number } Yes No The user changed the page or page size. Informative in client mode; in server mode load that page.
aui-filter-change { filters: Record<string, DataTableFilter>; search: string } Yes No Since 0.3.0. The user changed a column filter or the global search. The page returns to 1. In server mode load matching rows (read table.getState() for the full query).
aui-columns-change { column: string; reason: 'visibility' | 'resize'; columns: DataTableColumnsState } Yes No Since 0.3.0. The user showed or hid a column (column chooser) or resized one (on release, or per resize key).
aui-expand-change { id: string; expanded: boolean; expandedIds: string[] } Yes No Since 0.3.0. The user expanded or collapsed a row.
aui-group-toggle { key: string; collapsed: boolean; collapsedGroups: string[] } Yes No The user expanded or collapsed a group (since 0.9.0).
aui-cell-edit { rowId: string; row: Row; column: string; value: unknown; previous: unknown } Yes No The user committed an inline edit (since 0.9.0); update rows with value.
Area Description
column render() Custom cell content per column: a string (rendered as text) or a consumer-owned Node.
renderDetail() Since 0.3.0. Row detail content under an expanded row: a consumer-owned Node or a string (text). Kept while the row stays expanded and its object is unchanged.

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

Class Purpose
aui-data-table Host.
aui-data-table--compact | --comfortable | --spacious Density.
aui-data-table--sticky Sticky header enabled.
aui-data-table--loading Loading state.
aui-data-table--virtual Since 0.3.0. Virtual mode (the region gets max-block-size: 70vh; override it on the region).
aui-data-table--fixed Since 0.3.0. Fixed table layout with pixel column widths (after a resize or with columnWidths).
aui-data-table--pinned Since 0.3.0. At least one column is pinned.
aui-data-table--resizing Since 0.3.0. A pointer resize is in progress.
aui-data-table__toolbar | __search | __search-label | __search-input Since 0.3.0. Search toolbar (role=search) and its labelled field.
aui-data-table__control Since 0.3.0. Native filter and search fields.
aui-data-table__body Positioning wrapper for the region and the loading status.
aui-data-table__region Focusable, labelled scroll region.
aui-data-table__table The <table>.
aui-data-table__caption The <caption>.
aui-data-table__head | __header-row The <thead> (sticky as a whole) and its header row.
aui-data-table__header-cell Column header <th scope=col>.
aui-data-table__header-cell--sorted A sorted column header (every sort column).
aui-data-table__sort | __sort-label | __sort-icon | __sort-priority Sort button and its parts (priority number since 0.3.0, shown while sorting by several columns).
aui-data-table__resizer Since 0.3.0. Column resize handle (role=separator).
aui-data-table__filter-row | __filter-cell | __filter-input | __filter-range | __filter-select Since 0.3.0. Filter row and its controls.
aui-data-table__cell--pinned | --pinned-start | --pinned-end | --pinned-edge Since 0.3.0. Sticky cells of pinned columns; the edge class marks the innermost pinned column of each side.
aui-data-table__expand-cell | __expander | __expander-icon Since 0.3.0. Disclosure column and its button.
aui-data-table__detail-row | __detail Since 0.3.0. Detail row of an expanded row and its full-width cell.
aui-data-table__spacer Since 0.3.0. Virtual mode spacer rows (aria-hidden).
aui-data-table__row Body row.
aui-data-table__row--selected Selected row.
aui-data-table__cell Body cell (td, or th scope=row for the row header).
aui-data-table__cell--end | --center Cell and header alignment.
aui-data-table__select-cell Selection column cell.
aui-data-table__box | __check | __check--checkbox | __check--radio | __mark Native selection input and its mark.
aui-data-table__select-all Select-all checkbox.
aui-data-table__empty-row | __empty Empty state row and cell.
aui-data-table__skeleton-row | __skeleton Decorative loading placeholders.
aui-data-table__status | __status--overlay | __spinner Loading status region and overlay.
aui-data-table__pagination Host of the composed Pagination.
aui-data-table__visually-hidden Visually hidden text (caption, labels).
Slot Default icon
sortAscending arrow-up
sortDescending arrow-down
sortNone arrow-up-down
checked check
indeterminate minus
loading spinner
expand chevron-right
collapse chevron-down

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

--aui-border-width | --aui-color-active | --aui-color-border | --aui-color-border-strong | --aui-color-disabled-bg | --aui-color-error-fg | --aui-color-focus-ring | --aui-color-hover | --aui-color-on-primary | --aui-color-primary | --aui-color-selected | --aui-color-selected-text | --aui-color-surface | --aui-color-surface-sunken | --aui-color-text | --aui-color-text-muted | --aui-control-height-sm | --aui-duration-fast | --aui-easing-standard | --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-lg | --aui-icon-size-md | --aui-icon-size-sm | --aui-line-height-normal | --aui-radius-md | --aui-radius-pill | --aui-radius-sm | --aui-space-1 | --aui-space-2 | --aui-space-3 | --aui-space-4 | --aui-space-8 | --aui-touch-target-min

type DataTableColumnType = 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'boolean';
interface DataTableSort { column: string; direction: 'ascending' | 'descending' }
type DataTableFilter =
| { type: 'text'; value: string; operator?: 'contains' | 'equals' | 'startsWith' }
| { type: 'number'; min?: number | null; max?: number | null }
| { type: 'date'; from?: string | null; to?: string | null }
| { type: 'enum'; values: readonly string[] };
interface DataTableColumn<Row> {
id: string; header: string; accessor: (row: Row) => unknown;
type?: DataTableColumnType; currency?: string;
format?: (value: unknown, row: Row) => string;
render?: (row: Row, doc: Document) => Node | string;
sortable?: boolean; compare?: (a: Row, b: Row) => number;
align?: 'start' | 'end' | 'center'; width?: string; rowHeader?: boolean;
filter?: 'text' | 'number' | 'date' | 'enum'; filterOptions?: readonly { value: string; label: string }[];
searchable?: boolean; hideable?: boolean; resizable?: boolean; minWidth?: number; maxWidth?: number;
exportable?: boolean; exportValue?: (row: Row) => string | number | boolean | null;
}
interface DataTableState {
version: 1;
sort: DataTableSort[];
filters: Record<string, DataTableFilter>;
search: string;
columns: { order: string[]; hidden: string[]; widths: Record<string, number>; pinned: { start: string[]; end: string[] } };
page: number;
pageSize: number | null;
}
interface DataTableSortChangeDetail { sort: DataTableSort | null; sorts: DataTableSort[] }
interface DataTableSelectionChangeDetail { selected: string[] }
interface DataTablePageChangeDetail { page: number; pageSize: number }
interface DataTableFilterChangeDetail { filters: Record<string, DataTableFilter>; search: string }
interface DataTableColumnsChangeDetail { column: string; reason: 'visibility' | 'resize'; columns: DataTableState['columns'] }
interface DataTableExpandChangeDetail { id: string; expanded: boolean; expandedIds: string[] }
class DataTable<Row> {
static create<Row>(host: HTMLElement, options: DataTableOptions<Row>): DataTable<Row>;
getState(): DataTableState;
setState(state: DataTableStateInput): void;
toCsv(options?: DataTableCsvOptions): string;
toCsvBlob(options?: DataTableCsvOptions): Blob;
}
class DataTableColumnChooser<Row> {
static create<Row>(trigger: HTMLButtonElement, table: DataTable<Row>, options?: DataTableColumnChooserOptions): DataTableColumnChooser<Row>;
}
function downloadCsv(csv: string | Blob, filename: string, doc?: Document): void;

Pattern: WAI-ARIA APG Table pattern using a native HTML <table> (not the Grid pattern) with sortable column headers (APG ‘Sortable Table’ example): header buttons plus aria-sort on the primary sorted column. Since 0.3.0: column resize handles follow the APG Window Splitter pattern (focusable separator with value); row expansion follows the APG Disclosure pattern; the column chooser is an APG Menu Button with menuitemcheckbox items; virtual rows use aria-rowcount/aria-rowindex.

Key Context Action
Tab / Shift+Tab Any Moves through the search field, the scroll region, sort buttons, resize handles, filter controls, select all, row selection inputs, disclosure buttons, interactive cell and detail content, and pagination controls (native order).
Enter / Space Sort button focused Sorts by this column only (ascending, descending, then unsorted in tri-state).
Shift+Enter / Shift+Space / Shift+click Sort button, multiSort enabled Adds the column to the sort as the lowest priority, or cycles it in place (removed after descending in tri-state).
ArrowRight / ArrowLeft Resize handle focused Widens / narrows the column by 10 px in ltr (mirrored in rtl); with Shift by 50 px. Emits aui-columns-change.
Home / End Resize handle focused Sets the column to its minimum / maximum width.
Typing Text or number filter, search field Filters after filterDelay (default 250 ms); Enter applies immediately.
Enter / Space Disclosure button focused Expands or collapses the row’s detail row; focus stays on the button.
Space Checkbox focused Toggles the row, or all selectable rows on the page (native).
Arrow keys Row radio focused (single selection) Selects the previous/next row (native radio group).
Arrow keys Scroll region focused Scrolls the table (native); in virtual mode rows render as they scroll into view.
Shift+click Row checkbox Sets every selectable row between the last clicked row and this one (current page) to this row’s new state.
Enter / Space / ArrowDown Column chooser button Opens the menu (APG Menu Button); Space on an item toggles the column; Escape closes and returns focus.
Enter / Space Editable cell button Opens the cell editor (checkbox columns toggle the value in one step); focus moves into the editor.
Enter / Tab (leaving the cell) Cell editor Commits the value; a validation message keeps the editor open with aria-invalid and the message.
Escape Cell editor Cancels the edit and returns focus to the cell button.
Enter / Space Group header toggle Collapses or expands the group (aria-expanded).
Element Role
<div class=aui-data-table__region> region, named by the caption; tabindex=0 so keyboard users can scroll
<table> table (native)
<caption> table caption (native accessible name)
<th scope=col> columnheader (native)
<th scope=row> rowheader (native)
<button> in a header button (native) that changes the sort
<input type=checkbox | radio> checkbox / radio (native) for row selection
<div class=aui-data-table__status> status (announces ‘Loading’)
composed Pagination navigation landmark named ‘{caption}, pagination’
<div class=aui-data-table__toolbar> search landmark named by the caption (searchable only)
<input type=search> in the toolbar searchbox (native), labelled by a visible ‘Search’ label
filter row <input>/<select> searchbox, spinbutton, date input, or combobox (native), each with an aria-label naming the column
<span class=aui-data-table__resizer> separator (focusable, aria-orientation=vertical)
<button class=aui-data-table__expander> button (native) with aria-expanded (disclosure)
<tr class=aui-data-table__detail-row> row with one full-width cell holding the detail content
DataTableColumnChooser Menu button (aria-haspopup=menu, aria-expanded) with role=menu and menuitemcheckbox items
Editable cell button inside the cell (td/th), named by the cell value plus “, edit {column}” (visually hidden); checkbox columns use aria-pressed
Group header row row with a cell spanning all columns; the toggle is a button with aria-expanded, named “{column}: {value} ({count})”
Attribute Element Value
aria-labelledby scroll region The caption id
aria-sort the primary sorted column header only ascending | descending (absent on other columns, including secondary sort columns)
button text (visually hidden) sort buttons of sort columns while sorting by several columns ‘sort priority {n}, ascending | descending’ appended to the header text; the visible number is aria-hidden
aria-label column headers (resizable only) The header text, so the resize handle’s name is not part of the column header name
aria-describedby sort buttons (multiSort only) A hidden hint: ‘Hold Shift while activating to sort by more than one column.’
aria-label select-all checkbox Localized ‘Select all rows’; indeterminate property exposes mixed state natively
aria-label row checkbox / radio Localized ‘Select row {row header text}’ (row id when there is no row header)
aria-label filter controls ‘Filter {column}’, ‘Minimum/Maximum {column}’, ‘{column} from/to’
aria-label resize handle ‘Resize {column}’
aria-valuenow / aria-valuemin / aria-valuemax / aria-valuetext resize handle Width in CSS pixels, the column’s minWidth/maxWidth, and ‘{width} pixels’ (measured when the handle receives focus)
aria-expanded / aria-controls disclosure button true | false; aria-controls points at the detail row while it is shown
aria-label disclosure button Localized ‘Details for {row header text}’
aria-rowcount / aria-rowindex table / header rows and rendered body rows (virtual mode only) Header rows plus all rows of the filtered set; 1-based positions including header rows and the page offset
aria-busy scroll region true while loading
aria-hidden skeleton rows, virtual spacer rows, sort, mark, and disclosure icons, sort priority number true (decorative)
disabled selection inputs of unselectable rows Native disabled

Header buttons persist across sorts, so focus stays on the activated button. Row elements are reused by row id and unchanged rows are not moved, so focus inside them survives selection changes, expansion, and data refreshes. Filter fields are not overwritten while focused. In virtual mode the row that holds focus stays rendered while it scrolls out of the window, so focus is never lost to the document. Pagination keeps focus on its own controls after a page change.

Criterion Level Note
1.3.1 Info and Relationships A
1.3.2 Meaningful Sequence A
1.3.5 Identify Input Purpose AA
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.4.3 Focus Order A
2.4.6 Headings and Labels AA
2.4.7 Focus Visible AA
2.5.7 Dragging Movements AA
2.5.8 Target Size (Minimum) AA
3.3.2 Labels or Instructions A
4.1.2 Name, Role, Value A
4.1.3 Status Messages AA

Automated test results: Accessibility and compliance.

  • Sort changes are announced politely: ‘Sorted by {column}, ascending’ / ‘descending’, ‘Sorted by A, ascending, then by B, descending’ for several columns, or ‘Sorting cleared’. Programmatic sorts are silent.
  • Filter and search changes are announced politely in client mode: ‘Results: {count}’. In server mode the consumer’s new rows update the pagination summary.
  • Selected rows use the checked input plus a row class (aui-data-table__row–selected); aria-selected is not used because this is a plain table, not a grid.
  • The loading status lives outside the busy region so its ‘Loading’ text is announced.
  • Select all applies to the current page; its checked/indeterminate state reflects only that page.
  • Resize handles are 1rem wide, centered on the column edge; the pointer path needs a drag (WCAG 2.5.7): keyboard resizing and columnWidths/setState are the alternatives.
  • Manual screen-reader verification (NVDA, JAWS, VoiceOver) is pending.
  • The scroll region is always a tab stop, even when the table does not overflow.
  • Each resize handle is a tab stop, which doubles header tab stops with resizable: true.
  • Virtual mode exposes only rendered rows to assistive technology; aria-rowcount and aria-rowindex convey the full size and positions.

default, row hover, row selected, row not selectable (disabled input), sorted ascending/descending, unsorted, sorted by several columns (priority numbers), filtered, focus-visible, loading with skeleton rows, loading with an overlay over existing rows, empty (no rows or no matches), density (compact/comfortable/spacious), sticky header, column hidden, column resized (fixed layout), resizing, columns pinned, row expanded, virtual.

From Trigger To
unsorted sort button ascending; page 1; announcement; aui-sort-change
ascending sort button descending
descending sort button unsorted (tri-state) or ascending (bi-state)
sorted by A Shift + sort button B (multiSort) sorted by A, then B ascending; priorities shown
sorted by A, B Shift + sort button B B cycles in place; after descending (tri-state) B is removed
sorted by several sort button without Shift only that column, continuing its own cycle
any filter field input (after filterDelay), Enter, select or date change filtered; page 1; “Results: n” announced (client); aui-filter-change
any search field input searched; page 1; aui-filter-change
column visible chooser item hidden; aui-columns-change (visibility); the last visible column cannot be hidden
auto layout first resize (pointer or key) fixed layout: every header gets its measured pixel width
width w ArrowRight / ArrowLeft / Home / End on a handle, or drag new width within min/max; aui-columns-change (resize) per key or on release
collapsed row disclosure button expanded: detail row after the row; aui-expand-change
expanded row disclosure button collapsed: detail row removed
any page Pagination control new page (and size); aui-page-change
unselected row row checkbox / radio selected; aui-selection-change
anchor row set Shift+click another row checkbox every selectable row between them takes the clicked row’s new state
some or none selected on page select all every selectable row on the page selected
all selected on page select all every row on the page deselected
rows present update({ loading: true }) overlay spinner; rows kept
no rows update({ loading: true }) skeleton rows
any setRows(rows) re-render; rows whose object is unchanged keep their element; selection pruned to present ids (client mode)
any setState(state) the given parts restored; no events
  • User sort changes are announced politely: “Sorted by Customer, ascending”, “Sorted by Dept, ascending, then by Salary, descending”, or “Sorting cleared”.
  • Client-side filter and search changes are announced politely: “Results: 12”.
  • Sort buttons persist across sorts, so focus stays put. Unchanged rows are neither rebuilt nor moved, so focus inside them survives selection changes, expansion, and refreshes. Filter fields are not overwritten while they have focus.
  • Virtual mode keeps the row that holds focus rendered while it scrolls out of the window, so focus never falls back to the document.
  • Select all applies to the current page (every row of it in virtual mode). Its state reflects only that page; selections on other pages are kept. Filters do not clear the selection.
  • Selected rows are shown by fill and the checked mark, not color alone. Radios show a filled dot.
  • Pagination announces the new range (“Showing 11-20 of 57”) after a page change.
  • Resizing by pointer needs a drag. Its alternatives are keyboard resizing on the focused handle and columnWidths/setState (WCAG 2.5.7; see Known limitations).
  • Each resize handle is a tab stop, so resizable: true doubles the header’s tab stops.
  • Manual screen-reader verification is pending (see Known limitations).

The table sits in a scroll region (overflow: auto) that keyboard users can focus and scroll, so wide tables never force page-level horizontal scrolling (WCAG 1.4.10). With stickyHeader (default), the whole head (header and filter rows) is position: sticky inside the region; give the region a max-block-size (for example .orders .aui-data-table__region { max-block-size: 60vh; }) for it to stick on long tables. Pinned columns stay in view while the region scrolls horizontally, which keeps the row identity visible on narrow screens; pin few columns there, because they do not scroll. The search toolbar wraps; filter fields keep a minimum width and scroll with the table. Virtual mode gives the region max-block-size: 70vh. The composed Pagination switches to “Page 3 of 10” in narrow containers.

Layout uses logical properties and follows the document direction; end-aligned numeric columns align to the left edge in rtl. Pinned columns use inset-inline-start/inset-inline-end, so start pins sit on the right in rtl and the edge boundary follows. Resize handles sit on each column’s inline-end edge; in rtl ArrowLeft widens a column and a drag to the left widens it (the direction is read from the computed style, so dir on any ancestor works). The collapsed disclosure chevron is mirrored. The direction option is passed to Pagination, which mirrors its chevrons. Sort arrows point up and down and are not mirrored.

Colors come from role tokens only. Headers and the filter row use color-surface-sunken, hovered rows color-hover, and selected rows color-selected with color-selected-text. Pinned cells get an opaque background that follows the row state (surface, hover, selected) and a color-border-strong boundary on the innermost pinned column. Filter and search fields use color-surface with a color-border-strong boundary; native date and number fields follow the theme’s color-scheme. Detail rows use color-surface-sunken. Selection inputs match Checkbox. In forced-colors mode selected rows get a Highlight outline, inputs use Canvas, CanvasText, and Highlight, and resize handles and pinned boundaries use CanvasText.

configureAbsoluteUI({ components: { dataTable: { selection: 'multiple', pageSize: 50, sortCycle: 'bi-state', multiSort: true, filterDelay: 300 } } });

Changes apply to existing instances immediately (rows are rebuilt).

Instance options override global and theme defaults. All options are dynamic. A page size chosen by the user in the rows-per-page select overrides configuration until update({ pageSize }) is called. View state (sort, filters, search, column order/visibility/widths/pinning, page, page size) can be set with individual options, with update(), or all at once with setState().

datetime columns expect an Instant (or ZonedDateTime) and format it with formatInstant in the resolved timezone (instance, then component, then common configuration; UTC by default). The host machine’s zone is never used, and a ZonedDateTime is also shown in the configured zone so a column never mixes zones. For example, instant(Date.UTC(2024, 0, 15, 15, 30)) shows “Jan 15, 2024, 3:30 PM” in UTC and “Jan 15, 2024, 10:30 AM” in America/New_York. date columns expect a PlainDate and never shift. Sorting uses epoch milliseconds (datetime) and calendar fields (date).

Date filters (since 0.3.0) on datetime columns compare the calendar date in the configured timezone: from and to days are converted to the start instants of those days in the zone (compatible disambiguation, so a day that starts in a DST gap starts at the first valid time), and a value matches when it falls in [start of from, start of the day after to). So 2024-01-15T03:00Z matches the day 2024-01-14 in America/New_York but 2024-01-15 in UTC. On date columns the filter compares calendar fields only. Raw CSV exports write YYYY-MM-DD for dates and ISO 8601 UTC (Z) for instants; formatted exports use the configured zone.

icons.sortAscending (default arrow-up), icons.sortDescending (arrow-down), icons.sortNone (arrow-up-down), icons.checked (check), icons.indeterminate (minus), icons.loading (spinner), and since 0.3.0 icons.expand (chevron-right, mirrored in rtl) and icons.collapse (chevron-down).

All cell text uses textContent. format() results and render()/renderDetail() strings are text, never HTML. Nodes returned by render() and renderDetail() are consumer-owned and appended as-is, so their safety is the consumer’s responsibility. Column widths must match ^\d+(\.\d+)?(px|rem|em|%|ch)$ (the width option) or be finite pixel numbers (columnWidths); widths, the fixed layout, and pinned offsets (CSS custom properties) are written with style.setProperty. CSP-safe, including Trusted Types (no HTML sinks).

CSV export (since 0.3.0) follows RFC 4180: fields that contain the delimiter, a double quote, CR, or LF are quoted and quotes are doubled. Formula injection (OWASP “CSV Injection”): a cell whose text starts with =, +, -, @, a tab, or a carriage return is prefixed with a single quote and quoted, so spreadsheet applications show it as text. This applies to headers, format() output, text values, and strings from exportValue. Numbers the table formats itself (number and currency columns without format), raw numbers and booleans, and numbers or booleans returned by exportValue are trusted: they cannot carry a formula, so -5 stays a number. escapeFormulas: false disables the protection; use it only for consumers that are not spreadsheets. toCsv() and toCsvBlob() have no side effects; downloadCsv() is the only function that starts a download, through a temporary object URL (revoked after one second) and an <a download> element that is removed immediately.

setState() treats its input as untrusted (for example parsed from storage or a URL): malformed structure throws DATA_TABLE_STATE and leaves the table unchanged.

No persistence, network access, or telemetry. Rows are held by reference and never mutated. In server mode the component only emits events; you fetch data. getState() returns a JSON-safe snapshot; storing it (for example in localStorage) is your choice, and it contains filter and search text the user typed. CSV exports contain whatever the chosen columns show; exclude sensitive columns with exportable: false.

  • Rows are built into a DocumentFragment on first render. Afterwards row elements are reused by row id while the row object and rendering inputs (columns, column layout, locale, timezone, selection mode, configuration) are unchanged; unchanged rows are not moved.
  • Only the current page (or, in virtual mode, the rows in view plus 6 rows of overscan on each side, at most 300) is in the DOM.
  • Sorted order is cached until rows, sorts, columns, or locale change, so paging and filtering do not re-sort. Filtered rows are cached until the sorted rows, filters, or search change.
  • Sort keys are computed once per row and sort column. Text keys are ranked once with one Intl.Collator (numeric: true) per locale, so multi-column comparisons are numeric.
  • Text filters and search use a per-column cache of the lower-cased displayed text (built on first use, invalidated when rows, columns, locale, timezone, or configuration change).
  • Delegated listeners on the head, the body, the toolbar, and the region; one Pagination listener; one configuration subscription; one shared ResizeObserver entry for the region and one per pinned header cell. Filter typing is debounced (filterDelay).
  • Resizing and pinning read layout (header cell widths) only when those features are in use.
  • Budgets (headless Chromium, e2e performance test; sort, filter, and CSV figures are the median of 5): rendering 1,000 rows x 8 columns with pageSize: null under 150 ms; sorting 10,000 rows with pageSize: 25 under 100 ms for text columns and under 50 ms for numeric columns; multi-column sort (text then number) of 10,000 rows under 120 ms; filtering 10,000 rows under 50 ms; creating a virtual table of 100,000 rows under 200 ms and re-rendering its window after a scroll under 16 ms (median of 10 jumps); toCsv() of 10,000 rows x 8 columns under 150 ms. For larger client-side data sets, sort and filter on the server (mode: 'server').

Client mode is tested with 10,000 rows paged (filtering, multi-sort, CSV), 1,000 rows x 8 columns rendered at once, and 100,000 rows with virtual: true. Virtual scrolling is bounded by the browser’s maximum element height (about 33,000,000 px in Chromium, roughly 800,000 rows of 40 px). For larger sets, use server mode. Page counts follow Pagination (any integer).

  • null, undefined, NaN, and values that do not match the column type render as empty cells, sort last in both directions, and never match number or date ranges.
  • Equal sort keys keep their original order (stable sort), also across several sort columns.
  • Text sorting is locale-aware with numeric collation: “Item 2” sorts before “Item 10”.
  • A custom compare receives rows and is negated for descending; nulls-last handling is then up to the comparator.
  • A sort on a column that is removed or made unsortable by update({ columns }) is dropped; so are filters the new columns cannot take, and hidden, width, order, and pin entries of removed columns.
  • A plain (non-Shift) sort activation while sorting by several columns sorts by that column only, continuing its own cycle (a descending secondary column becomes unsorted in tri-state).
  • Filters on hidden columns stay active; the search only covers visible, searchable columns.
  • A programmatic enum filter with several values shows “n selected” in the filter row; choosing an option there replaces it.
  • Shrinking rows or filtering clamps the page. total: 0 or no matching rows shows the empty message and hides the pagination.
  • update({ selection: 'none' }) clears the selection; switching to 'single' keeps the most recent id.
  • Server mode keeps selected and expanded ids from other pages; call clearSelection() when the data set changes.
  • Shift+click with no anchor on the current page (for example after paging) selects just that row and sets the anchor.
  • The first resize switches the table to a fixed layout with every header’s measured width; after a column is shown or hidden the layout is measured again (resized widths are kept). Widths are border-box pixels.
  • pinnedColumns entries for hidden columns are ignored while hidden. Start pins also pin the selection and disclosure columns, which sit at the start edge.
  • hiddenColumns must leave one column visible; a setState snapshot that would hide every column keeps all columns visible.
  • A detail row renders its content once per row object; setRows with new row objects renders it again for expanded rows.
  • Virtual mode with a region that has no bounded height renders at most 300 rows; bound the region.
Code Category Cause
DATA_TABLE_OPTIONS configuration Empty caption; empty columns; duplicate or empty column id; empty header; missing accessor; unknown type or align; currency column without an ISO 4217 code; invalid width; more than one rowHeader: true; sort on an unknown or unsortable column, or a column twice; invalid selected, pageSizes, page, mode, total, loading, or emptyMessage; a pageSize not in pageSizes; more than one selected id with single selection; server mode without total while paged. Since 0.3.0: a filter kind the column type cannot take, filterOptions without an enum filter, invalid minWidth/maxWidth/booleans on a column; filters for unknown columns or of the wrong type; invalid search, hiddenColumns (unknown ids, or all columns), columnOrder, columnWidths, pinnedColumns (unknown ids, or a column on both edges), renderDetail, isRowExpandable, expanded, rowHeight; virtual together with renderDetail; a column chooser without a DataTable.
DATA_TABLE_ROW_ID configuration rowId returned an empty or non-string value, or two rows share an id.
DATA_TABLE_STATE configuration Since 0.3.0. setState input that is not a version: 1 state object, or whose parts have the wrong structure. The table is unchanged.
DATA_TABLE_CSV configuration Since 0.3.0. Invalid toCsv options (unknown column id, delimiter, rows, values, line ending) or an empty downloadCsv file name.
MENU_TRIGGER configuration The column chooser trigger is not a <button> attached to the document.
CFG_INVALID_VALUE configuration Invalid selection, pageSize, stickyHeader, sortCycle, multiSort, filterDelay, locale, timezone, density, or direction.

A failed construction (including an exception thrown by a consumer render/format) leaves no rendering, listeners, or subscriptions behind. A failed update or setState leaves the previous state in place.

  • Plain table semantics: no cell-by-cell arrow-key navigation or cell selection. Inline editing opens one editor at a time, and editable cells are tab stops.
  • No column reorder by drag, nested grids, or infinite scrolling. Column order is programmatic (columnOrder, setState). Grouping and aggregation run in client mode only and not with virtual.
  • Pointer resizing needs a drag; the alternatives are the keyboard handle and columnWidths/setState (WCAG 2.5.7 is met through those alternatives; flagged for accessibility review).
  • Enum filters in the filter row choose one value; several values are programmatic.
  • Text filters and search are case-insensitive substring matches on displayed text; accents and punctuation are significant.
  • Filters and search are not applied in server mode; the consumer applies them.
  • Virtual rows are single-line (white-space: nowrap) and must share one height; virtual mode cannot be combined with row expansion.
  • Select all covers the current page only; there is no “select all N results”. Filters do not change the selection.
  • Range selection works within the current page.
  • The sticky header needs a max-block-size on the region to stick on long tables.
  • The scroll region is always a tab stop; each resize handle and each editable cell is a tab stop.
  • Screen-reader behavior has not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • ARIA grid semantics and arrow-key cell navigation.
  • Infinite scrolling and lazily loaded virtual windows.
  • HTML strings in cells or detail rows.
  • virtual: true with renderDetail.