ngx-virtual-grid

July 6, 2026 ยท View on GitHub

npm version npm downloads license bundle size

A responsive virtual-scrolling grid for Angular with built-in infinite scroll. Uses CSS Grid for layout, auto-measures item dimensions, and only renders what's visible.

Angular CDK's virtual scroller only supports single-column lists. If you need a responsive grid with virtual scrolling, ngx-virtual-grid fills that gap.

Live Demo | GitHub | npm

Why ngx-virtual-grid?

Angular CDK's cdk-virtual-scroll-viewport only handles single-column lists. If you need a responsive multi-column grid with virtual scrolling, you're on your own.

ngx-virtual-grid gives you a real CSS Grid that only renders visible items. You control the layout with standard grid-template-columns and gap - the library reads the computed grid to figure out column count and row height automatically. No config objects, no pixel math.

It also works as a single-column virtual list - just set grid-template-columns: 1fr.

Features

  • Virtual scrolling with CSS Grid layout
  • Auto-measures item dimensions from the first rendered row
  • Responsive - adapts to column count changes via CSS
  • Infinite scroll with configurable threshold
  • Pagination support - start at any page, accumulate data as you scroll
  • Skeleton loading - show placeholder items while data loads
  • Works as a grid or a single-column list
  • Works with both zoned and zoneless Angular apps
  • SSR-safe with prerendering support

Installation

npm install @theryansmee/ngx-virtual-grid
yarn add @theryansmee/ngx-virtual-grid
pnpm add @theryansmee/ngx-virtual-grid

Angular Version Support

Each Angular major version is maintained on its own branch:

BranchAngularLibrarynpm tag
angular/1414.x14.x.xangular14
angular/1515.x15.x.xangular15
angular/1616.x16.x.xangular16
angular/1717.x17.x.xangular17
angular/1818.x18.x.xangular18
angular/1919.x19.x.xangular19
angular/2020.x20.x.xangular20
angular/2121.x21.x.xangular21
angular/2222.x22.x.xlatest

The main branch tracks the latest stable version.

Feature availability: Pagination and skeleton loading require 22.1.0+, 21.1.0+, 20.1.0+, or 19.1.0+. They are not available on the Angular 14-18 branches.

Usage

Import the component and directive directly (standalone):

import { Component } from '@angular/core';
import { NgxVirtualGridComponent, VirtualGridItemDirective, VirtualGridSkeletonDirective } from '@theryansmee/ngx-virtual-grid';

@Component({
  selector: 'app-example',
  imports: [NgxVirtualGridComponent, VirtualGridItemDirective, VirtualGridSkeletonDirective],
  template: `
    <ngx-virtual-grid
      [items]="items"
      [bufferSize]="3"
      [loadMoreThreshold]="0.8"
      (loadMore)="onLoadMore()">

      <ng-template ngxVirtualGridItem let-item let-index="index">
        <div class="card">{{ item.name }}</div>
      </ng-template>
    </ngx-virtual-grid>
  `,
  styles: [`
    ngx-virtual-grid {
      grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
      gap: 16px;
    }
  `],
})
export class ExampleComponent {
  items: any[] = [];

  onLoadMore(): void {
    // Load more items...
  }
}

Grid or list - your call

The layout is controlled entirely by CSS. The component is a CSS Grid container, so you just set grid-template-columns on it like you would any grid.

Responsive multi-column grid:

ngx-virtual-grid {
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 16px;
}

Fixed 3-column grid:

ngx-virtual-grid {
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

Single-column list:

ngx-virtual-grid {
  grid-template-columns: 1fr;
  gap: 8px;
}

Same component, same API - the layout adapts automatically based on your CSS.

Pagination

Requires 22.1.0+, 21.1.0+, 20.1.0+, or 19.1.0+ (not available on Angular 14-18).

For large datasets where you load pages of data from an API, use the page and pageSize inputs. The library creates virtual space above loaded data using page * pageSize and uses loadMore to grow downward - just like infinite scroll.

@Component({
  // ...
})
export class SearchResultsComponent {
  items: Result[] = [];
  firstLoadedPage: number = 0;
  lastLoadedPage: number = -1;
  isLoading: boolean = false;
  readonly pageSize: number = 50;

  constructor() {
    // Load initial page (e.g. from URL param)
    this.loadPage(0);
  }

  onLoadMore(): void {
    // loadMore fires when scrolling down - append the next page
    this.loadPage(this.lastLoadedPage + 1);
  }

  onPageNeeded(page: number): void {
    // may be several pages above the loaded data after a fast scroll.
    // load one adjacent page at a time; the grid re-emits until covered.
    this.loadPage(Math.max(page, this.firstLoadedPage - 1));
  }

  onPageChanged(page: number): void {
    // Update URL so the user can navigate back to this position
  }

  loadPage(page: number): void {
    if (this.isLoading) {
      return;
    }

    this.isLoading = true;
    this.api.getResults(page, this.pageSize).subscribe(response => {
      if (this.items.length === 0) {
        this.firstLoadedPage = page;
        this.lastLoadedPage = page;
        this.items = response.items;
      } else if (page < this.firstLoadedPage) {
        this.firstLoadedPage = page;
        this.items = [...response.items, ...this.items];
      } else {
        this.lastLoadedPage = page;
        this.items = [...this.items, ...response.items];
      }
      this.isLoading = false;
    });
  }
}
<ngx-virtual-grid
  [items]="items"
  [page]="firstLoadedPage"
  [pageSize]="pageSize"
  [loading]="isLoading"
  (loadMore)="onLoadMore()"
  (pageNeeded)="onPageNeeded($event)"
  (pageChanged)="onPageChanged($event)">

  <ng-template ngxVirtualGridItem let-item>
    <div class="result">{{ item.name }}</div>
  </ng-template>
</ngx-virtual-grid>

How it works:

  • page is the page of the first item in your array (0-indexed). Keep it at the lowest loaded page; the library turns page * pageSize into virtual space above
  • loadMore fires when scrolling down approaches the end of loaded items - append the next page. The threshold is measured within the loaded data, so deep-linking to a high page doesn't fire it on arrival
  • pageNeeded asks for earlier pages. Normally that means page - 1 as the user nears the top of loaded data, but a fast scroll that jumps above the loaded data emits the page under the viewport instead - prepend down to it. If more pages are still needed, the grid asks again each time page changes
  • Prepending an earlier page never re-triggers loadMore - the library detects prepends and keeps its forward-load state
  • pageChanged fires when the viewport center crosses a page boundary - useful for updating the URL
  • Items accumulate as the user scrolls, and you never need to know the total count - the bottom just grows via loadMore like a normal infinite scroller

Skeleton loading

Requires 22.1.0+, 21.1.0+, 20.1.0+, or 19.1.0+ (not available on Angular 14-18).

Show placeholder items while data loads. Provide a skeleton template and set loading to true - the library renders the right number of skeletons to fill the visible area, matching the grid layout.

<ngx-virtual-grid
  [items]="items"
  [loading]="isLoading"
  (loadMore)="onLoadMore()">

  <ng-template ngxVirtualGridItem let-item>
    <app-card [data]="item"></app-card>
  </ng-template>

  <ng-template ngxVirtualGridSkeleton>
    <app-card-skeleton></app-card-skeleton>
  </ng-template>
</ngx-virtual-grid>

Works with both paginated and non-paginated modes:

  • Non-paginated: skeletons appear below loaded items when loading is true
  • Paginated: skeletons fill visible slots in virtual space above (unloaded earlier pages) and below (loadMore pending)
  • Initial load: when items is empty and loading is true, skeletons fill the viewport and are used for dimension measurement

The skeleton count is calculated automatically - same number of items the virtual scroller would normally render (viewport rows x columns + buffer).

Drop it anywhere on the page

You don't need to wrap your entire page in this component. It works alongside other content - just put it wherever you need a virtual list or grid:

<h1>My Dashboard</h1>
<p>Some intro text, a navbar, whatever you want up here.</p>

<ngx-virtual-grid [items]="products" (loadMore)="loadMoreProducts()">
  <ng-template ngxVirtualGridItem let-product>
    <app-product-card [product]="product" />
  </ng-template>
</ngx-virtual-grid>

<footer>Still works down here too.</footer>

By default it listens for scroll events on window, so it just works as part of your normal page scroll. No need for a fixed-height wrapper or any special container setup.

Custom scroll container

If you do want to put it inside a scrollable container (like a panel or sidebar), pass the container element as scrollParent:

<div #scrollContainer style="height: 600px; overflow-y: auto;">
  <ngx-virtual-grid [items]="items" [scrollParent]="scrollContainer">
    <ng-template ngxVirtualGridItem let-item>
      <div class="card">{{ item.name }}</div>
    </ng-template>
  </ngx-virtual-grid>
</div>

API

Inputs

InputTypeDefaultDescription
itemsunknown[][]Array of data items to render
bufferSizenumber3Number of extra rows to render above and below the viewport
loadMoreThresholdnumber0.8Scroll ratio (0-1) at which the loadMore event fires. Measured within the loaded data, so deep-linked pages don't fire immediately
scrollParentHTMLElement | nullnullCustom scroll container. Uses window if null
pagenumber0The page (0-indexed) that the first item in items belongs to. Keep it set to the lowest loaded page - it creates page * pageSize items of virtual space above.
pageSizenumber0Number of items per page. Enables pagination when > 0.
loadingbooleanfalseWhen true and a skeleton template is provided, renders skeleton placeholders in visible slots that don't have data.

Outputs

OutputTypeDescription
loadMorevoidEmits when the scroll position crosses the loadMoreThreshold within the loaded data. Re-arms when more items are appended; prepending an earlier page does not re-arm it.
pageNeedednumberAsks for earlier pages: emits page - 1 when the viewport nears the top of loaded data, or the viewport's own page after a fast scroll jumps above it. Asks again on each page change until the viewport is covered.
pageChangednumberEmits the current page number when the viewport center crosses a page boundary. Useful for updating the URL.

Methods

MethodDescription
scrollToIndex(index: number)Scroll to bring the item at index into view
scrollToOffset(pixels: number)Scroll to an absolute pixel offset within the grid
scrollToPage(page: number)Scroll to the start of the given page (requires pageSize > 0)
refresh()Re-measure dimensions and recalculate layout

Template context

The ngxVirtualGridItem template receives:

VariableTypeDescription
$implicitTThe data item
indexnumberThe item's global index. Same as the array index in non-paginated mode; offset by page * pageSize in paginated mode

The ngxVirtualGridSkeleton template receives $implicit as the global index of the skeleton slot.

Zoneless apps

The library works with both zoned and zoneless Angular apps. In zoneless mode, the loadMore output emits from a raw scroll event listener. If your handler modifies component state, use signals so the view updates:

items = signal<Item[]>([]);

onLoadMore(): void {
  // Signal write triggers change detection in zoneless mode
  this.items.update(current => [...current, ...newItems]);
}

Prerequisites

  • Node.js 22.22+
  • pnpm 11+
  • Angular 22.x

Development

# Install dependencies
pnpm install

# Build the library
pnpm run build:lib

# Start the demo app (builds library first, then serves demo)
pnpm start

The demo app runs at http://localhost:4200/.

Available scripts

ScriptDescription
pnpm run build:libBuild the library for production
pnpm run build:demoBuild the demo application
pnpm startBuild library + serve demo app
pnpm testRun library unit tests (watch mode)
pnpm run test:ciRun library unit tests (single run)
pnpm run lintLint all projects
pnpm run lint:fixLint and auto-fix all projects

Publishing

pnpm run build:lib
cd dist/ngx-virtual-grid
pnpm publish

When publishing older Angular version branches, use the version-specific tag so it doesn't become latest:

pnpm publish --tag angular21

Contributing

  1. Branch off the appropriate angular/* branch for your target Angular version
  2. Follow the existing code style (tabs, explicit types, explicit accessibility modifiers)
  3. Add unit tests for new functionality
  4. Ensure pnpm run lint and pnpm run test:ci pass before opening a PR

License

MIT