Table: virtualized rows
[forTableVirtualized] renders a window of rows instead of the whole dataset, while aria-rowcount and each row's aria-rowindex go on reporting the true totals, so a grid of tens of thousands of rows scrolls at a fixed cost and still tells a screen reader where the user is.
It is opt-in and works only with <div role> grid mode. Native <table> cannot omit rows mid-body
(the browser recalculates all column widths when any row is missing), so virtualization requires the
<div> structure documented in
the table README. This guide also covers the
scroll-container choice and the ARIA reindexing contract. Split out of the table README in
#1401 because it spans two entry points; the
adapter itself ships from the third,
forty-cdk/table-virtualization, and the
table is documented in the table README.
Place [forTableVirtualized] on the same element as [forTable]. Set [rowCount] on [forTable] to the true total row count. This drives both aria-rowcount and the window size, and for an index-addressable dataset it is the whole configuration. An append-style infinite list splits the two: see Append-style lists below.
<div
#scroll
forTable
forTableVirtualized
mode="grid"
ariaLabel="Big table"
[rowCount]="10000"
[estimateRowSize]="44"
[scrollElement]="scrollEl()"
#v="forTableVirtualized"
style="height: 400px; overflow: auto; position: relative;"
>
<div forTableHeaderRow style="position: sticky; top: 0;">
<div forTableHeaderCell name="name">Name</div>
</div>
<div role="rowgroup" [style.height.px]="v.totalSize()" style="position: relative">
@for (vrow of v.virtualRows(); track vrow.index) {
<div
#row
forTableRow
[virtualIndex]="vrow.index"
[style.transform]="'translateY(' + vrow.start + 'px)'"
style="position: absolute; left: 0; right: 0;"
>
<div forTableCell name="name">{{ data()[vrow.index]!.name }}</div>
</div>
}
</div>
</div>
Key points:
- The sticky header rowgroup lives outside the absolutely-positioned body so it is not clipped by the scroll container's overflow.
- The body rowgroup is
position: relativeand sized tov.totalSize(). This creates the full scroll range. - Each row is
position: absolute; transform: translateY(vrow.start + 'px'). Do not usetop, becausetransformavoids layout thrashing. - Bind
[virtualIndex]="vrow.index"on each[forTableRow]. This is what drives the absolute 1-basedaria-rowindex(vrow.index + 1) rather than the DOM-order index. - The focused row stays mounted even when scrolled out of the window. The roving-focused
gridcellis never unmounted; roving navigation is unchanged. - For measured (variable) row heights, call
v.measureRow(el)per rendered row inafterEveryRender. Each[forTableRow]reflects its[virtualIndex]asdata-index, the attribute the core reads to know which row an element is, so give the rows a template reference (#row) and query them;measureRow(null)then sweeps the rows recycled out of the window.
import { afterEveryRender, type ElementRef, viewChild, viewChildren } from '@angular/core';
import { ForTableVirtualized } from 'forty-cdk/table-virtualization';
private readonly v = viewChild.required(ForTableVirtualized);
private readonly rowEls = viewChildren<ElementRef<HTMLElement>>('row');
constructor() {
afterEveryRender(() => {
for (const row of this.rowEls()) {
this.v().measureRow(row.nativeElement);
}
this.v().measureRow(null);
});
}
Append-style lists: [virtualRowCount]
[rowCount] answers "how many rows does the dataset have"; [virtualRowCount] answers "how many rows can the virtualizer place". They default to the same number, which is correct for an index-addressable dataset: the window can travel to any absolute index because the page behind it is fetchable on arrival.
An append-style infinite list (load 30, concatenate, load 30 more at the bottom) can only render its loaded prefix, so the two part ways. Keep [rowCount] at the server-known total and bind [virtualRowCount] to the loaded count:
<div
forTable
forTableVirtualized
mode="grid"
[rowCount]="serverTotal()"
[virtualRowCount]="loaded().length"
#v="forTableVirtualized"
></div>
Both halves matter. Raising [rowCount] alone inflates the scroll range with rows that will never mount (the thumb shrinks to a sliver and the viewport scrolls into empty space), while lowering it to the loaded count announces an aria-rowcount that is wrong on every page but the last. [virtualRowCount] also bounds cross-window keyboard navigation, so Ctrl+End lands on the last loaded row rather than stashing a focus move that only resolves when a far page appends.
A server that returns no total is the same shape with [rowCount] left unbound: aria-rowcount reports -1, the value ARIA reserves for an unknown total, rather than the number of mounted rows, and [virtualRowCount] alone bounds the scroll range and the cross-window navigation.
Raw-primitive rendering has no other channel for that count: <for-table-body> derives the loaded count from its own dataset (for the navigation bound), a table rendering its own rows does not.
Scroll container (table root vs. ancestor)
By default the table root is the scroll container: the element carrying [forTableVirtualized] scrolls its own rows (the overflow: auto element in the examples above), so [scrollElement] can be left unset.
When the element that actually scrolls is an ancestor of the table (e.g. an app-shell viewport that scrolls projected content), the table cannot inject a scroll container it does not own. Bind [scrollElement] to that ancestor by hand (a template reference variable is the simplest source):
<div #shell style="height: 100vh; overflow: auto;">
<!-- other app-shell content scrolls together with the table -->
<div
forTable
forTableVirtualized
mode="grid"
ariaLabel="Big table"
[rowCount]="10000"
[scrollElement]="shell"
#v="forTableVirtualized"
style="position: relative;"
>
<!-- header + windowed rows exactly as above -->
</div>
</div>
Wrapping: re-exposing / renaming scrollElement
A design-system wrapper that re-exposes ForTableVirtualized through hostDirectives can surface scrollElement directly, or rename it, via input aliasing. No bridging effect is needed because the value flows straight through:
import { Component } from '@angular/core';
import { ForTableVirtualized } from 'forty-cdk/table-virtualization';
@Component({
selector: 'my-data-grid',
hostDirectives: [
{
directive: ForTableVirtualized,
inputs: ['scrollElement: scrollContainer'],
},
],
})
export class MyDataGrid {}
Consumers of the wrapper then bind [scrollContainer]="shell".
[forTableVirtualized] inputs
| Input | Type | Default | Description |
|---|---|---|---|
estimateRowSize | number | 44 | Estimated row height in px. Used as the fixed size in fixed-size mode and as the initial estimate in measured mode. |
scrollElement | HTMLElement | null | null | Explicit scroll container. Defaults to the table root element; bind to an ancestor when it owns the scroll. |
virtualRowCount | number | undefined | the table's [rowCount] | Count of rows the virtualizer can place: the scroll range and the cross-window navigation bound. Bind it for an append-style infinite list. |
[forTableVirtualized] API (#v="forTableVirtualized")
| Member | Type | Description |
|---|---|---|
virtualRows() | Signal | The visible window plus overscan, always including the focused row. |
range() | Signal | The rendered window as [firstIndex, lastIndex + 1), sourced from the true virtualizer window (not the focus-augmented virtualRows()). Plugs straight into injectInfiniteScroll. |
totalSize() | Signal | Total scroll height of all rows in px. Bind to the body container height. |
scrollToRow(index, options?) | method | Scroll the container so row index is in view. |
measureRow(el) | method | Record a rendered row element's measured size (for dynamic row heights). |
Tree-shaking
@tanstack/virtual-core only loads when you import ForTableVirtualized. A plain ForTable never pulls in the virtualization core.