Sub-header rows
Group rows under a column-driven header
Sub-header rows group rows under a visible header row driven by one column: pass a leaf column id through subHeaderColumn, and the renderer keeps the groups contiguous by always sorting by that column first. The grouping engine lives in the shared table state, so <nat-table> and <nat-list> behave identically.
When To Use Sub-Headers
Use sub-headers when one column is really a section, not a value the user compares per row — statuses, categories, owners, dates bucketed by day. The column can stay visible as a normal column or be hidden through column visibility; grouping works either way because it reads row values, not rendered cells.
Composition
<nat-table [columns]="columns" [data]="rows" accessibleName="Orders" subHeaderColumn="status" />
subHeaderColumn accepts a leaf column id. When it is unset — or does not match any leaf column — the feature is fully inert: no forced sort, no group rows, and a dev-mode warning for unknown ids.
The same inputs exist on the list renderer:
<nat-list [columns]="columns" [data]="rows" accessibleName="Orders" subHeaderColumn="status" />
Per-Renderer Opt-Out
enableSubHeaders (default true) is a renderer-level gate: set it to false to ignore the sub-header config on that renderer only — useful when the same bound inputs drive a table and a list and only one of them should group:
<nat-table [columns]="columns" [data]="rows" [enableSubHeaders]="false" accessibleName="Orders" subHeaderColumn="status" />
A disabled renderer is fully inert: no forced sort, no group rows, and no dev-mode warnings about the ignored config.
Sorting Semantics
The renderer always sorts by the sub-header column first through a hidden forced ascending entry. That entry exists only in the state handed to TanStack:
sortingChange,[(state)], andinitialStateonly ever contain user sorting.aria-sort, sort indicators, and multi-sort priority badges never show the forced sort.- The sort button on the sub-header column is suppressed — toggling it would change hidden state with no visible effect.
User sorting stays the visible primary sort and applies within groups: clicking "Total" sorts totals inside each status group, with or without multi-sort enabled.
With manualSorting, the server owns row order — the forced entry is inert and never leaked, and sub-header rows still render whenever the column value changes between consecutive rows.
Custom Group Order
subHeaderOrder replaces the ascending group order with an explicit value order:
<nat-table
[columns]="columns"
[data]="rows"
[subHeaderOrder]="['Ready', 'Review', 'Queued']"
accessibleName="Orders"
subHeaderColumn="status" />
Values missing from the array sort after every listed value, in natural ascending order among themselves. subHeaderOrder without subHeaderColumn is ignored (dev-mode warning).
Custom Sub-Header Content
By default the sub-header renders the group value as text (an empty value renders an empty row). Project ng-template[natTableSubHeader] to take over:
<nat-table [columns]="columns" [data]="rows" accessibleName="Orders" subHeaderColumn="status">
<ng-template let-count="rowCountValue" let-value natTableSubHeader>
<strong>{{ value }}</strong>
<span class="muted">{{ count }} orders</span>
</ng-template>
</nat-table>
The template context carries value (also $implicit), rowCountValue — the group's total size across the sorted and filtered dataset, ignoring pagination — the group's first row, and the table instance.
Pagination And Pinned Columns
- When a group spans a page boundary, the next page repeats the group's sub-header above its first row, and
rowCountValuestill reports the whole group. - In the table, the sub-header is a full-width row whose content is sticky to the leading edge, so the label stays visible while pinned columns and horizontal scrolling move the grid under it.
Accessibility
Each sub-header row carries screen-reader text such as "Ready group, 3 rows." (the list says items), generated by the subHeaderRow and listSubHeaderRow locale formatters and overridable per locale like every other announcement. The default visible value is hidden from screen readers so groups are not announced twice.
Styling
Sub-header rows read the public tokens --nat-table-sub-header-background, --nat-table-sub-header-color, --nat-table-space-sub-header, and --nat-table-font-weight-sub-header in both renderers. The list's sub-header defaults to no horizontal padding so it aligns with the list items; set --nat-table-space-sub-header to change it. See Theming for the full token contract.
Rows grouped by status
A hidden forced sort keeps groups contiguous in both renderers while user sorting stays visible and applies within groups.
<nat-table-surface [(state)]="state">
<nat-table
[columns]="columns"
[data]="rows"
[subHeaderOrder]="useStatusOrder() ? statusOrder : undefined"
accessibleName="Orders grouped by status"
subHeaderColumn="status">
<ng-template let-count="rowCountValue" let-value natTableSubHeader>
<span class="sub-header-demo-label">{{ value }}</span>
<span class="sub-header-demo-count">{{ count }} orders</span>
</ng-template>
</nat-table>
</nat-table-surface>protected readonly statusOrder: readonly string[] = ['Ready', 'Review', 'Queued'];
protected readonly state = signal<Partial<NatTableUserState>>({});
// User sorting stays the visible sort and applies within groups — the forced
// group sort never appears in the surface state. The demo button cycles
// not sorted → descending → ascending and mirrors the direction (↕ / ↓ / ↑).
protected cycleSortByTotal(): void {
const direction = this.sortDirection();
let sorting: NatTableUserState['sorting'] = [];
if (direction === null) {
sorting = [{ id: 'total', desc: true }];
} else if (direction === 'desc') {
sorting = [{ id: 'total', desc: false }];
}
this.state.update((current) => ({ ...current, sorting }));
}nat-table,
nat-list {
--nat-table-sub-header-background: color-mix(in srgb, currentcolor 6%, transparent);
--nat-table-font-weight-sub-header: 600;
}