forty-cdk
llms.txt
Guides

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: relative and sized to v.totalSize(). This creates the full scroll range.
  • Each row is position: absolute; transform: translateY(vrow.start + 'px'). Do not use top, because transform avoids layout thrashing.
  • Bind [virtualIndex]="vrow.index" on each [forTableRow]. This is what drives the absolute 1-based aria-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 gridcell is never unmounted; roving navigation is unchanged.
  • For measured (variable) row heights, call v.measureRow(el) per rendered row in afterEveryRender. Each [forTableRow] reflects its [virtualIndex] as data-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

InputTypeDefaultDescription
estimateRowSize
number
44Estimated row height in px. Used as the fixed size in fixed-size mode and as the initial estimate in measured mode.
scrollElement
HTMLElement | null
nullExplicit 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")

MemberTypeDescription
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.