Utilities
Virtualization
A headless windowing core (injectVirtualizer) plus an ergonomic [forVirtualViewport] + *forVirtualFor layer that render only the visible slice of huge lists. Fixed or measured item sizes, horizontal lists, scroll-to-index, and an infinite-scroll detector. List primitives (Select, Combobox, Listbox, Tree, Table) compose it directly.
Scroll the list and watch the DOM: only the rows in view exist, each carrying its own data-index, while the sizer keeps the scrollbar honest.
Given a reactive item count, a size estimator, and a scroll container, the core returns the slice of items currently visible (plus overscan), the total scroll size, and imperative scroll/measure helpers. The consumer renders the items with their own @for and applies the position transform. This primitive owns no DOM. Backed internally by @tanstack/virtual-core. SSR-safe: off-browser it returns an empty window and the estimate-based total without touching document/window.
Ships from the
forty-cdk/virtualizationsecondary entry point. Import every symbol below (injectVirtualizer,ForVirtualViewport,ForVirtualFor,injectInfiniteScroll) fromforty-cdk/virtualization, notforty-cdk. This keeps@tanstack/virtual-coreout of the bundle for apps and routes that don't virtualize.This entry point imports no other primitive. The two adapters that compose one ship from their own specifiers:
forty-cdk/table-virtualization([forTableVirtualized], which also needsforty-cdk/table) andforty-cdk/virtual-reorder([forVirtualReorder], which also needsforty-cdk/drag-drop).
Ergonomic layer
For the common "just virtualize this list" case, the optional Shape A layer wraps the manual
wiring: [forVirtualViewport] owns the scroll container, the total-size sizer, and the windowing
core, and *forVirtualFor renders the visible window with the position transform and
aria-setsize / aria-posinset applied for you.
<div forVirtualViewport [virtualCount]="rows().length" [estimateSize]="44" style="height: 400px">
<div *forVirtualFor="let row of rows(); let item = virtualItem">{{ row.label }}</div>
</div>
readonly rows = signal(Array.from({ length: 10000 }, (_, i) => ({ label: `Row ${i}` })));
The viewport forces overflow: auto on its host; give it a fixed size (e.g. height: 400px).
orientation, overscan, and getItemKey are optional inputs on [forVirtualViewport]; set
orientation / overscan before first render (they are read once when the viewport initializes).
The template context exposes row ($implicit), virtualItem, index, and count. Do not set
position / transform on the row yourself, because the directive owns them.
*forVirtualFor writes the flat aria-setsize (the total count) and aria-posinset (index + 1)
on each row root on every render, so it fits a flat list. A collection that binds its own positions,
such as a tree whose [forTreeItem] binds [setSize] / [posInSet] per level, uses injectVirtualizer
directly, as the Tree README's virtualized example does.
For full control (custom DOM, dynamic per-item measurement, a window/document scroller) use the
headless injectVirtualizer core directly, documented below.
Examples
Dynamic heights (measured)
When rows vary in height, drop to the headless injectVirtualizer core: it owns no DOM, so the consumer renders the spacer and the absolutely-positioned window. Each row carries [attr.data-index] and is fed to measureElement() in afterEveryRender, so estimates refine and jumping to the bottom lands precisely.
Infinite scroll (endReached)
The Shape A turnkey path: bind (endReached) on [forVirtualViewport] and it builds the infinite-scroll detector internally, firing once when the rendered window comes within the overscan of the end. The consumer owns the fetch and appends the next page; the detector re-arms when the bound count grows.
loaded: 30 / 600 — status: idle
Vertical list
<div #scroll style="overflow: auto; height: 400px">
<div [style.height.px]="v.totalSize()" style="position: relative">
@for (item of v.virtualItems(); track item.key) {
<div
[attr.data-index]="item.index"
[attr.aria-setsize]="items().length"
[attr.aria-posinset]="item.index + 1"
[style.position]="'absolute'"
[style.top.px]="item.start"
[style.height.px]="item.size"
[style.width]="'100%'"
>
{{ items()[item.index] }}
</div>
}
</div>
</div>
readonly items = signal(Array.from({ length: 10000 }, (_, i) => `Row ${i}`));
readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
readonly v = injectVirtualizer({
count: computed(() => this.items().length),
estimateSize: () => 40,
scrollElement: this.scrollElement,
});
The spacer div (the one bound to totalSize()) is position: relative so the
absolutely positioned item slices stay inside the scroll container. Each item is
positioned with top: item.start instead of translateY so jsdom-based tests can
read the value without CSS layout; prefer transform: translateY(item.start + 'px') translateZ(0) in production for GPU compositing.
Dynamic item heights
When items have variable heights, query the rendered elements and feed each one to
measureElement so the virtualizer refines its estimates. The item element must
carry [attr.data-index]="item.index" so the virtualizer can look up which row the
element belongs to:
@for (item of v.virtualItems(); track item.key) {
<div
#row
[attr.data-index]="item.index"
[attr.aria-setsize]="items().length"
[attr.aria-posinset]="item.index + 1"
[style.position]="'absolute'"
[style.top.px]="item.start"
[style.width]="'100%'"
>
{{ items()[item.index] }}
</div>
}
readonly rows = viewChildren<ElementRef<HTMLElement>>('row');
constructor() {
afterEveryRender(() => {
for (const row of this.rows()) {
this.v.measureElement(row.nativeElement);
}
});
}
Horizontal list
Set orientation: 'horizontal' and apply translateX instead of translateY.
The totalSize() drives the spacer's width rather than height:
<div #scroll style="overflow: auto; display: flex; width: 600px">
<div [style.width.px]="v.totalSize()" style="position: relative; height: 100%">
@for (item of v.virtualItems(); track item.key) {
<div
[attr.data-index]="item.index"
[style.position]="'absolute'"
[style.left.px]="item.start"
[style.width.px]="item.size"
[style.height]="'100%'"
>
{{ items()[item.index] }}
</div>
}
</div>
</div>
readonly v = injectVirtualizer({
count: computed(() => this.items().length),
estimateSize: () => 80,
scrollElement: this.scrollElement,
orientation: 'horizontal',
});
Jumping to an item
this.v.scrollToIndex(500, { align: 'start' });
align accepts 'start' | 'center' | 'end' | 'auto' (default). 'auto'
scrolls the minimum amount needed to bring the item into view.
Drag-reorder
[forVirtualReorder] makes a windowed *forVirtualFor list reorderable by pointer
and keyboard, translating the drop into dataset-absolute indices so the right
item moves even when the lifted row scrolls out of the rendered window. It composes
[forDropList], so it ships from its own entry point rather than this one.
Infinite scroll
Two shapes are available: a turnkey output on [forVirtualViewport] (Shape A) and a
composable injectInfiniteScroll core for manual wiring (Shape B).
Shape A — (endReached) output
Wire directly onto [forVirtualViewport]; the viewport builds the detector internally:
<div
forVirtualViewport
[virtualCount]="rows().length"
[estimateSize]="44"
(endReached)="loadMore()"
style="height: 400px"
>
<div *forVirtualFor="let row of rows(); let item = virtualItem">{{ row.label }}</div>
</div>
Shape B — injectInfiniteScroll
Compose with the headless core when you need pending state or custom threshold/disabled.
The consumer owns the fetch and the data accumulation; the library decides when to ask:
readonly v = injectVirtualizer({ count: this.count, estimateSize: () => 40, scrollElement: this.scrollEl });
readonly loader = injectInfiniteScroll({
range: this.v.range,
count: this.count,
disabled: computed(() => !this.hasMore()),
onLoadMore: () => this.fetchNextPage(),
});
The detector fires once per threshold crossing, is suppressed while the onLoadMore promise is
pending (loader.pending() reflects the in-flight state), and re-arms when count grows (a page
was appended). An empty [0, 0] window (including SSR off-browser) never fires. The consumer owns
the fetch, deduplication, and retry. Angular resource() / httpResource() are a natural fit.
| Option | Type | Default | Description |
|---|---|---|---|
range | Signal | required | The rendered window from injectVirtualizer(...).range. |
count | Signal | required | Reactive total number of currently-loaded items. |
threshold | number | 5 | Fire when the window's last index is within this many items of count. |
disabled | Signal | — | When true the detector never fires. |
onLoadMore | () => void | Promise | required | Called once per threshold crossing; returning a promise arms pending. |
Composing into a list primitive
range lets the windowing core plug directly into a list primitive's [visibleRange] input without the consumer re-deriving the window from virtualItems(). The primitive uses [visibleRange] to keep aria-setsize / aria-posinset and aria-activedescendant correct across row recycling: it tracks option data by absolute index so options scrolled out of view are still reachable by keyboard.
[totalCount]="filtered().length" [visibleRange]="v.range()"
(scrollToIndex)="v.scrollToIndex($event)"
See the Combobox README for the complete worked example wiring [forCombobox] with injectVirtualizer over a 100k-item list.
API
Options
| Property | Type | Description |
|---|---|---|
count | Signal | Reactive total number of items. Default: required |
estimateSize | (index: number) => number | Estimated pixel size along the scroll axis for the item at index.Default: required |
scrollElement | Signal | Reactive scroll container. Default: required |
orientation | 'vertical' | 'horizontal' | Scroll axis. Default: 'vertical' |
overscan | number | Extra items to render beyond the visible window on each side. Default: 5 |
getItemKey | (index: number) => string | number | Stable key per item; used by @for (track item.key).Default: (i) => i |
Returned handle
| Member | Type | Description |
|---|---|---|
virtualItems | Signal | Items in the current visible window plus overscan. |
totalSize | Signal | Total scroll size in pixels (drives the spacer element). |
range | Signal | The [firstIndex, lastIndex + 1) rendered window, [0, 0] when empty. Feeds a list primitive's [visibleRange]. |
scrollToIndex | method | Scroll the container so the item at index is in view. |
scrollToOffset | method | Scroll to an absolute pixel offset. |
measureElement | method | Record the measured size of a rendered item element. |
Accessibility
Virtual lists render only a window of items, so screen readers see a shorter list than the true total. Bind the full list size so assistive technology announces the real count:
aria-setsize: the total number of items in the full (non-windowed) list.aria-posinset: the 1-based position of the item in that full list (item.index + 1).
<div [attr.aria-setsize]="items().length" [attr.aria-posinset]="item.index + 1"></div>
Wrapping in a design system
Subclass the root and re-provide FOR_VIRTUAL_VIEWPORT_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.