ngx-virtual-scroller-flexible

June 3, 2026 · View on GitHub

npm version Angular

An ultra-fast, flexible virtual scroller for Angular that renders unlimited items with variable heights and multi-column layouts. Built on top of Angular CDK's CdkVirtualScrollViewport with a custom scroll strategy that measures actual DOM element sizes.

Perfect for image galleries, product grids, chat feeds, or any list where items have different heights.

Features

  • Variable-height items — measures real DOM elements instead of requiring fixed row heights
  • Multi-column grid layouts — responsive column count based on container width
  • Configurable buffer zones — separate incoming/outgoing buffer factors for smooth scrolling
  • Infinite scroll — built-in scrolledToEnd / scrolledToStart events for loading more data
  • Inverted scrolling — for chat-style bottom-to-top layouts
  • Resize-aware — automatically remeasures when the viewport resizes
  • Standalone — works with Angular's standalone component architecture
  • Zoneless-compatible — works with provideZonelessChangeDetection()

Installation

npm install @onexip/ngx-virtual-scroller-flexible

Peer dependencies

PackageVersion
@angular/core^20.0.0 || ^21.0.0
@angular/common^20.0.0 || ^21.0.0
@angular/cdk^20.0.0 || ^21.0.0

Quick start

1. Define your Track models

Every item in the scroller must extend the Track class:

import { Track } from '@onexip/ngx-virtual-scroller-flexible';

export class ImageTrack extends Track {
  constructor(public images: Image[], public columns: number) {
    super();
  }

  trackId(): string {
    return this.images.map(img => img.id).join('-');
  }

  // Tracks with the same sizeId are assumed to have the same height.
  // The scroller measures one example per sizeId.
  sizeId(): string {
    return 'image-row';
  }
}

2. Set up the component

import { Component, computed, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import {
  ExampleBasedVirtualScrollDirective,
  InfiniteScrollEndComponent,
  distinctSizeIds,
  gridTracks,
  responsiveOrthogonalTrackCount,
  Track,
} from '@onexip/ngx-virtual-scroller-flexible';

@Component({
  selector: 'app-my-list',
  standalone: true,
  imports: [
    ScrollingModule,
    ExampleBasedVirtualScrollDirective,
    InfiniteScrollEndComponent,
    MyRowComponent,
  ],
  templateUrl: './my-list.component.html',
})
export class MyListComponent {
  private images = signal<Image[]>([]);
  columns = signal(1);

  tracks = computed(() =>
    gridTracks(this.images(), this.columns())
      .map(group => new ImageTrack(group, this.columns()))
  );

  // Number of distinct sizeIds your tracks produce
  readonly DISTINCT_SIZES = 1;

  sizeExamples = computed(() =>
    distinctSizeIds(this.tracks(), this.DISTINCT_SIZES)
  );

  tracker = (index: number, track: Track) => track.trackId();

  updateColumns(contentRect: DOMRectReadOnly) {
    const breakpoints = [400, 800, 1200];
    this.columns.set(responsiveOrthogonalTrackCount(breakpoints, contentRect.width));
  }

  fetchMore() {
    // Load next page of data
  }
}

3. Set up the template

<div class="scroller-container" cdkVirtualScrollingElement>
  <cdk-virtual-scroll-viewport
    appExampleBasedVirtualScroll
    [tracks]="tracks()"
    [expectedSameSizeCount]="DISTINCT_SIZES"
    [outgoingBufferFactor]="0.5"
    [incomingBufferFactor]="1.5"
    (resized)="updateColumns($event)"
  >
    <!-- Size examples: invisible elements measured by the strategy.
         One per distinct sizeId. Must have the data-example-size-id attribute. -->
    @for (track of sizeExamples(); track track.trackId()) {
      <app-my-row
        [attr.data-example-size-id]="track.sizeId()"
        [track]="track"
        class="row"
      />
    }

    <!-- Virtualized content -->
    <app-my-row
      *cdkVirtualFor="let track of tracks(); trackBy: tracker"
      [track]="track"
      class="row"
    />
  </cdk-virtual-scroll-viewport>

  <app-infinite-scroll-end
    [earlyTriggerFactor]="2"
    (endReached)="fetchMore()"
  />
</div>

API

ExampleBasedVirtualScrollDirective

Directive selector: [appExampleBasedVirtualScroll]

Applied to <cdk-virtual-scroll-viewport>, it provides a custom VIRTUAL_SCROLL_STRATEGY that measures example DOM elements to determine item heights.

Inputs

InputTypeDefaultDescription
tracksTrack[][]The array of track items to scroll over
expectedSameSizeCountnumberundefinedNumber of distinct size groups — stops measuring early when all groups are found
outgoingBufferFactornumber0.0Buffer behind the scroll direction as a factor of viewport height
incomingBufferFactornumber0.8Buffer ahead of the scroll direction as a factor of viewport height
incomingAssetPreparationFactornumber2Asset preparation range ahead of scroll as a factor of viewport height
invertedScrollingbooleanfalseInvert scroll direction (for bottom-to-top layouts)
triggerRemeasurebooleanfalseToggle to force remeasurement of example element sizes

Outputs

OutputTypeDescription
renderedRangeChangeRangeEmits when the rendered track range changes
renderedAssetRangeChangeRangeEmits when the asset preparation range changes
scrolledToEndvoidEmits once when the asset range reaches the last track
scrolledToStartvoidEmits once when the asset range reaches the first track

InfiniteScrollEndComponent

Selector: <app-infinite-scroll-end>

Place below the viewport to trigger data loading when the user approaches the end.

InputTypeDefaultDescription
earlyTriggerFactornumber1How early to trigger (multiplied by viewport height)
loadingbooleanfalseSuppresses the trigger while data is being fetched
OutputTypeDescription
endReachedvoidEmits when the scroll position is near the end

Utility functions

FunctionDescription
distinctSizeIds(tracks, expectedCount?)Returns tracks with unique sizeId values (for size examples)
gridTracks(elements, columns)Chunks a flat array into a 2D grid
responsiveOrthogonalTrackCount(breakpoints, containerSize)Returns column count based on container width and breakpoints

Track (abstract class)

Base class for all items in the scroller.

MethodReturnsDescription
trackId()stringUnique identifier for change tracking
sizeId()stringGroups items with the same height — the strategy measures one example per group

How it works

Unlike fixed-height virtual scrollers, this library uses example-based measurement:

  1. You provide invisible "example" elements — one per distinct sizeId — inside the viewport
  2. The strategy measures their actual rendered height via getBoundingClientRect()
  3. It uses those heights to calculate accumulated offsets and determine which tracks are visible
  4. On scroll, it updates the rendered range and applies CSS transforms for positioning

This means your items can have any height — headings, image rows, ads — as long as items with the same sizeId have the same height.

Example

A full working example is included in the source under src/example-usage/.

License

Dual-licensed under your choice of either:

  • MIT — permissive use, including commercial and closed-source applications.
  • GPL-3.0-only — copyleft use for GPL-compatible projects.

The npm package metadata uses MIT OR GPL-3.0-only, so consumers may choose the MIT license.

(c) 2024–2026 onexip GmbH