List renderer
nat-list on the shared table engine
NatList (<nat-list>) renders rows as stacked label/value items instead of a grid, driven by the same engine (NatTableState) as NatTable. Column definitions, surface state, companion controls, and the data lifecycle are shared, so a list is a renderer choice — not a second table implementation.
When To Use The List
Use the list when a grid stops earning its columns:
- Narrow viewports where a table would scroll horizontally — pair it with a consumer-owned breakpoint to swap renderers.
- Card-like records where each row reads better as a labeled block than as a row of cells.
- Renderer swaps inside one
nat-table-surface, because the surface state (sorting, column order, visibility, selection, pagination) survives the swap.
The list deliberately ships no header UI, resizing, pinning, or reorder affordances. Sorting and field order are consumer-owned: write the surface state instead.
Composition
The smallest composition is a surface for the state scope and the list itself:
<nat-table-surface>
<nat-list [columns]="columns" [data]="rows" accessibleName="Orders" />
</nat-table-surface>
columns accepts the same TanStack ColumnDef array the table uses. Column ids double as grid-area names for item layout (see Item Layout And Theming).
Because list items draw their own chrome, the surface's card padding collapses to 0 around a projected list by default; set --nat-table-space-card-list to reopen it. Tables keep the regular --nat-table-space-card padding.
Field Labels
Each item renders one field per visible column, label first, value second:
meta.label(or a stringheader) renders as the visible field label.meta.hiddenHeaderLabelrenders the label screen-reader-only — same contract as the table's hidden headers. Use it when the value is self-describing.- A non-string
headerdef (component, template, or function) renders throughflexRenderas the field label.
Values render through the same flexRender pipeline as table cells: strings, flexRenderComponent, and TemplateRef cells all work unchanged — the live example renders its status field with the same badge component the table cells use. Grid-coupled cell widgets (ngGridCellWidget) require the table's Aria grid context and cannot render inside a list.
Shared State And Companion Controls
NatList implements NatTableUiController, so surface-bound companion controls resolve it exactly as they resolve a table: nat-table-pagination pages it, nat-table-column-visibility toggles fields, and consumer search registered through NatTableService filters it. Programmatic state flows through the same two-way state binding or patchState:
protected sortByTotal(): void {
this.state.update((current) => ({ ...current, sorting: [{ id: 'total', desc: true }] }));
}
Sub-Header Rows
subHeaderColumn groups list items under sub-header items exactly as it groups table rows: the shared engine forces a hidden primary sort so groups stay contiguous, user sorting applies within groups, and each group renders an <li class="list-sub-header"> announced with item-flavored copy. subHeaderOrder, the natTableSubHeader template, and the per-renderer enableSubHeaders gate all work identically. See Sub-header rows for the full semantics.
Selection And Activation
enableRowSelection and selectionMode bridge the shared selection state. Pair them with withNatTableSelectionColumn(...) to render a real checkbox per item; selected items expose data-selected for styling. aria-selected is intentionally absent — it is invalid on role="listitem", and the checkbox conveys the state.
enableRowActivation (opt-in) renders a stretched activator button per item and emits rowActivate on click and Enter/Space. It is a real <button> because a focusable <li> exposes no interactive role to assistive technology; interactive controls inside fields stack above the activator, so a selection checkbox never triggers activation.
Two deliberate consequences of the stretched-button design:
- The activator's accessible name is the item's first visible field (label plus value, e.g. "Order ORD-201") — concise on purpose, since the item content is read as the list item body anyway. Order the columns so the identifying field comes first.
- The overlay owns mousedown across the item, so field text cannot be selected with the mouse while activation is enabled. Leave activation off (or trigger navigation from a dedicated control) when copyable values matter. A field whose column sets
meta.rowActivation: falseis the exception: it stacks above the activator, so clicks on it never activate and its text stays selectable. When every visible column opts out, no activator is rendered at all.
Item Navigation
enableItemNavigation (opt-in) switches the list to the same composite grid pattern the table uses: the whole list becomes one tab stop, Up/Down arrow keys move a roving focus between items, and the cell-interaction model handles controls inside an item — Enter steps in, Tab/Shift+Tab cycle through them, Escape returns to the item. Items render as role="row"/role="gridcell" instead of plain list items, and screen readers get item-phrased keyboard instructions (the listKeyboardInstructions locale entry, falling back to keyboardInstructions).
<nat-table-surface>
<nat-list
[columns]="columns"
[data]="data"
[enableItemNavigation]="true"
accessibleName="Operations list"
(rowActivate)="open($event)" />
</nat-table-surface>
Behavior changes while it is enabled:
- Items emit
rowActivateon click and on therowActivateshortcut directly — like table rows, and withoutenableRowActivation. The stretched activator button is not rendered (the focusable gridcell already carries an interactive role), so field text becomes mouse-selectable again. - Native controls inside fields (for example a selection checkbox) are managed into the roving tab order; clicks on them never trigger activation. A column with
meta.rowActivation: falseexcludes its whole field from pointer activation, padding included. Keyboard Enter/Space targets the item (focus never sits on a field), so it keeps activating while at least one visible column participates; once every visible column opts out, the item stops emittingrowActivatefor keyboard and click alike. - With multi selection the grid carries
aria-multiselectable, and each item row mirrors its selection state ontoaria-selectedalongsidedata-selected.
Leave it off for short lists: plain role="list" semantics are friendlier to screen-reader browse mode, and a handful of tab stops is not a traversal cost. Reach for it when the list is long enough that one tab stop per item would make keyboard traversal expensive.
Data Lifecycle
dataStatus drives the same loading, empty, and error model as the table, rendered as list items with a shared base shape. The table's natTableLoading / natTableEmpty / natTableError templates are accepted unchanged, and the error input carries the payload into the error template context.
Item Layout And Theming
Every item is a CSS grid of named field areas — area names are column ids — so consumers lay fields out freely without touching the renderer:
nat-list {
--nat-list-item-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
--nat-list-item-areas: 'id id status' 'customer owner total';
}
The full token list (--nat-list-*, including the body-state tokens) is documented in Theming, and the opt-in stock theme styles the list out of the box.
Accessibility
accessibleNameis required (the list takes nocaption); dev mode warns when it is missing.- The list summary announces items and fields where the grid announces rows and columns, via the
listSummary,listColumnVisibilityChange,listPageSizeChange,listPageChange,listSubHeaderRow, andlistKeyboardInstructionslocale entries — each falls back to its grid counterpart when only that one is overridden. - State changes (sorting, filtering, selection, pagination) are announced through the same live region as the table.
- With
enableItemNavigationthe keyboard instructions joinaria-describedby, and the roving gridcells take the same focus ring tokens as table cells (--nat-table-focus-ring-width,--nat-table-focus-ring-color).
Limitations
- No header UI, column resizing, pinning, or reorder affordances — drive sorting and field order through surface state.
- Grid-coupled cell widgets (
ngGridCellWidget) cannot render inside a list. nat-table-scroll-controlexpects a scrollable region; the list region does not scroll by default, so give it a height and overflow before pairing the two.
One surface, two renderers
Sorting written through the surface state survives swapping between the table and list renderers.
@if (view() === 'list') {
<nat-table-surface [(state)]="state">
<nat-list [columns]="columns" [data]="rows" accessibleName="Orders" />
</nat-table-surface>
} @else {
<nat-table-surface [(state)]="state">
<nat-table [columns]="columns" [data]="rows" accessibleName="Orders" />
</nat-table-surface>
}protected readonly view = signal<'table' | 'list'>('list');
protected readonly state = signal<Partial<NatTableUserState>>({});
// The list has no header UI — sorting is written through the surface state,
// so it survives swapping the renderer. 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 }));
}/* Named field areas: area names are the column ids from the shared defs. */
nat-list {
--nat-list-item-columns: minmax(0, 1fr) auto;
--nat-list-item-areas: 'id total' 'customer status';
}Custom cells and field values
Text, component, and template cells all render through the same flexRender pipeline the table uses.
<!-- The list renders cells through the same flexRender pipeline as the table. -->
<ng-template #totalTemplate let-context>
<strong>{{ context.getValue() | currency }}</strong>
</ng-template>
<nat-table-surface>
<nat-list [columns]="columns()" [data]="rows" accessibleName="Orders" />
</nat-table-surface>private readonly totalTemplate = viewChild<TemplateRef<unknown>>('totalTemplate');
protected readonly columns = computed<ColumnDef<Order, unknown>[]>(() => [
// 1. Text: return a string from the cell def.
{ accessorKey: 'customer', header: 'Customer', meta: { label: 'Customer' }, cell: (info) => info.getValue<string>() },
// 2. Component: flexRenderComponent with typed inputs.
{
accessorKey: 'status',
header: 'Status',
meta: { label: 'Status' },
cell: (info) => flexRenderComponent(OrderStatusBadge, { inputs: { status: info.getValue() } })
},
// 3. Template: return a TemplateRef; the cell context is the template's $implicit.
{ accessorKey: 'total', header: 'Total', meta: { label: 'Total' }, cell: () => this.totalTemplate() }
]);Loading, empty, and error items
dataStatus drives the built-in list state items; fetching and retry handling stay consumer-owned.
<!-- dataStatus drives the built-in loading, empty, and error list items. -->
<nat-table-surface>
<nat-list [columns]="columns" [data]="rows()" [dataStatus]="dataStatus()" accessibleName="Orders" />
</nat-table-surface>// The consumer owns fetching, retries, and error handling; the list only
// renders the state you hand it.
protected readonly dataStatus = signal<NatTableDataStatus>(NAT_TABLE_DATA_STATUS.loading);
protected readonly rows = signal<Order[]>([]);
private async load(): Promise<void> {
this.dataStatus.set(NAT_TABLE_DATA_STATUS.loading);
try {
this.rows.set(await this.api.fetchOrders());
this.dataStatus.set(NAT_TABLE_DATA_STATUS.success);
} catch {
this.dataStatus.set(NAT_TABLE_DATA_STATUS.error);
}
}/* All three states share one base shape; shared tokens restyle them together. */
nat-list {
--nat-list-state-justify: center;
--nat-list-state-min-height: 8rem;
}Row selection
The shared selection slice and withNatTableSelectionColumn render a real checkbox per list item.
<!-- Same selection engine as the table: enable it, add a selection column. -->
<nat-table-surface [(state)]="state">
<nat-list [columns]="columns" [data]="rows" [enableRowSelection]="true" accessibleName="Orders" />
</nat-table-surface>// withNatTableSelectionColumn renders a real checkbox per item. Its header is
// the select-all checkbox, which a list would otherwise repeat as the field
// label, so give the column a screen-reader-only label instead.
protected readonly columns = withNatTableSelectionColumn(orderColumns, { columnId: 'select' }).map((column) =>
column.id === 'select' ? { ...column, meta: { ...column.meta, hiddenHeaderLabel: 'Select order' } } : column
);
// Selection lives in the shared surface state, so it reads back like any slice.
protected readonly selectedCount = computed(() => Object.values(this.state().rowSelection ?? {}).filter(Boolean).length);/* Selected items expose data-selected for styling. */
nat-list {
--nat-list-item-background-selected: color-mix(in srgb, currentcolor 8%, transparent);
}Companion controls
Pagination and column-visibility companions resolve the list as their controller unchanged.
<!-- Companion controls resolve the list as their controller — nothing list-specific. -->
<nat-table-surface [(state)]="state">
<nat-table-column-visibility label="Fields" />
<nat-list [columns]="columns" [data]="rows" accessibleName="Orders" />
<nat-table-pagination [pageSizeOptions]="[5, 10, 20]" />
</nat-table-surface>// Mounting the pagination companion registers pagination on the surface, and
// the shared engine pages the list. Column-visibility chips toggle fields.
protected readonly state = signal<Partial<NatTableUserState>>({});