Skip to content

Virtual scroll

A signals-native, zoneless-ready virtual scroller for large fixed-height lists — it renders only the visible rows (plus a small overscan), so a 10,000-row list keeps a handful of DOM nodes. It’s the foundation for Tree / large List / DataTable virtualization. No Zone.js, no rxjs; SSR-safe.

Two directives from @axisui-ng/cdk:

  • [axVirtualViewport] — the scroll container (tracks scroll + size; exposes scrollToIndex).
  • *axVirtualFor — renders the visible slice, with *ngFor-style context.
import { AxVirtualViewportDirective, AxVirtualForDirective } from '@axisui-ng/cdk';
<div axVirtualViewport #vp="axVirtualViewport" class="h-80 rounded-md border border-border">
<div *axVirtualFor="let row of rows(); itemSize: 36; overscan: 6"
class="flex h-9 items-center px-3">
{{ row }}
</div>
</div>
<button (click)="vp.scrollToIndex(5000)">Jump to 5000</button>
InputTypeDefaultNotes
axVirtualForOfreadonly T[]Required. The full data array (of in microsyntax).
axVirtualForItemSizenumberRequired. Row height in px (itemSize:).
axVirtualForOverscannumber4Extra rows rendered beyond the viewport on each side (overscan:).
PartMeaning
let row of itemsthe data array (axVirtualForOf)
itemSize: 36required row height in px
overscan: 6extra rows rendered each side (default 4)
context{ $implicit, index, count, first, last } (like *ngFor)
  • Host: relative block overflow-auto — give it a bounded height (class="h-80").
  • Methods: scrollToIndex(index), scrollToOffset(px).
  • Export: #vp="axVirtualViewport".

The viewport tracks scrollTop (scroll event) and its height (ResizeObserver, started in afterNextRender) as signals. *axVirtualFor runs a pure computeRange(...) in an effect, diffing the rendered views (a Map<index, view> — only the delta is created/destroyed). Sizing is applied with Renderer2 (no [style.*]): one spacer carries the full scroll height; each row is absolutely positioned by its index. Before measurement (and on the server) it renders a one-screen window so output isn’t empty, then corrects on hydrate.

Only the visible rows are in the DOM (inherent to virtualization). For full-list semantics, add aria-rowcount / aria-setsize on your rows. The spacer is aria-hidden.

Fixed row height only; no variable/measured rows, horizontal virtualization, or sticky headers yet.

Interactive examples live in Storybook under CDK / Virtual scroll. Run pnpm storybook to explore them.