ngx-virtual-scroller-flexible
June 3, 2026 · View on GitHub
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/scrolledToStartevents 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
| Package | Version |
|---|---|
@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
| Input | Type | Default | Description |
|---|---|---|---|
tracks | Track[] | [] | The array of track items to scroll over |
expectedSameSizeCount | number | undefined | Number of distinct size groups — stops measuring early when all groups are found |
outgoingBufferFactor | number | 0.0 | Buffer behind the scroll direction as a factor of viewport height |
incomingBufferFactor | number | 0.8 | Buffer ahead of the scroll direction as a factor of viewport height |
incomingAssetPreparationFactor | number | 2 | Asset preparation range ahead of scroll as a factor of viewport height |
invertedScrolling | boolean | false | Invert scroll direction (for bottom-to-top layouts) |
triggerRemeasure | boolean | false | Toggle to force remeasurement of example element sizes |
Outputs
| Output | Type | Description |
|---|---|---|
renderedRangeChange | Range | Emits when the rendered track range changes |
renderedAssetRangeChange | Range | Emits when the asset preparation range changes |
scrolledToEnd | void | Emits once when the asset range reaches the last track |
scrolledToStart | void | Emits 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.
| Input | Type | Default | Description |
|---|---|---|---|
earlyTriggerFactor | number | 1 | How early to trigger (multiplied by viewport height) |
loading | boolean | false | Suppresses the trigger while data is being fetched |
| Output | Type | Description |
|---|---|---|
endReached | void | Emits when the scroll position is near the end |
Utility functions
| Function | Description |
|---|---|
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.
| Method | Returns | Description |
|---|---|---|
trackId() | string | Unique identifier for change tracking |
sizeId() | string | Groups 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:
- You provide invisible "example" elements — one per distinct
sizeId— inside the viewport - The strategy measures their actual rendered height via
getBoundingClientRect() - It uses those heights to calculate accumulated offsets and determine which tracks are visible
- 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.
Copyright
(c) 2024–2026 onexip GmbH