How to use virtual scrolling in Angular
Rendering thousands of items in a list can severely impact application performance, causing slow rendering and sluggish scrolling. With over 10 years of experience building Angular applications and as the creator of CoreUI, I’ve optimized data-heavy admin panels and dashboards where performance is critical. From my expertise, the most effective solution is Angular CDK’s virtual scrolling, which renders only visible items and dramatically improves performance for large datasets. This method can handle lists with tens of thousands of items while maintaining smooth 60fps scrolling.
Use Angular CDK’s cdk-virtual-scroll-viewport to render only visible items in large lists.
import { ScrollingModule } from '@angular/cdk/scrolling'
@Component({
selector: 'app-user-list',
standalone: true,
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="50" class="viewport">
<div *cdkVirtualFor="let user of users" class="item">
{{ user.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.viewport {
height: 400px;
border: 1px solid #ccc;
}
.item {
height: 50px;
padding: 10px;
}
`]
})
export class UserListComponent {
users = Array.from({length: 10000}, (_, i) => ({
id: i,
name: `User ${i}`
}))
}
How It Works
The cdk-virtual-scroll-viewport creates a scrollable container with a fixed height. The itemSize property tells the viewport that each item is 50px tall. The *cdkVirtualFor directive replaces Angular’s *ngFor and renders only the items currently visible in the viewport plus a small buffer. As you scroll, Angular destroys off-screen elements and creates new ones, keeping memory usage and DOM size minimal.
Installing Angular CDK
First, install the CDK package if you haven’t already:
ng add @angular/cdk
Import the ScrollingModule in your component:
import { ScrollingModule } from '@angular/cdk/scrolling'
@Component({
// ... component config
imports: [ScrollingModule, CommonModule]
})
The ng add command installs the package and sets up necessary dependencies. The ScrollingModule provides the virtual scrolling directives and viewport component needed for efficient list rendering.
Dynamic Item Sizes
Handle items with varying heights using template references:
@Component({
template: `
<cdk-virtual-scroll-viewport [itemSize]="60" class="viewport">
<div *cdkVirtualFor="let post of posts" class="post">
<h3>{{ post.title }}</h3>
<p>{{ post.excerpt }}</p>
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.viewport { height: 500px; }
.post {
padding: 15px;
border-bottom: 1px solid #eee;
}
`]
})
export class PostListComponent {
posts = Array.from({length: 5000}, (_, i) => ({
title: `Post ${i}`,
excerpt: `This is the excerpt for post ${i}...`
}))
}
When items have varying heights, use an average item size for the itemSize property. The viewport uses this as an estimate for scrollbar sizing and buffer calculations. Small variations in actual heights are handled automatically, though very large variations may cause slight scrollbar jumpiness.
Horizontal Virtual Scrolling
Create horizontally scrolling lists:
@Component({
template: `
<cdk-virtual-scroll-viewport
itemSize="200"
orientation="horizontal"
class="horizontal-viewport">
<div *cdkVirtualFor="let item of items" class="horizontal-item">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.horizontal-viewport {
width: 800px;
height: 200px;
white-space: nowrap;
}
.horizontal-item {
display: inline-block;
width: 200px;
height: 100%;
padding: 20px;
}
`]
})
export class HorizontalListComponent {
items = Array.from({length: 1000}, (_, i) => ({
id: i,
name: `Item ${i}`
}))
}
Setting orientation="horizontal" switches to horizontal scrolling. The itemSize now represents width instead of height. CSS must use inline-block or flex to arrange items horizontally, and the viewport needs a fixed width instead of height.
Scrolling to Specific Items
Programmatically scroll to any position in the list:
@Component({
template: `
<button (click)="scrollToIndex(500)">Go to item 500</button>
<button (click)="scrollToTop()">Back to top</button>
<cdk-virtual-scroll-viewport
#viewport
itemSize="50"
class="viewport">
<div *cdkVirtualFor="let item of items; let i = index" class="item">
{{ i }}: {{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`
})
export class ScrollableListComponent {
@ViewChild('viewport') viewport: CdkVirtualScrollViewport
items = Array.from({length: 10000}, (_, i) => ({ name: `Item ${i}` }))
scrollToIndex(index: number) {
this.viewport.scrollToIndex(index)
}
scrollToTop() {
this.viewport.scrollToIndex(0, 'smooth')
}
}
The ViewChild decorator provides access to the viewport instance. The scrollToIndex() method jumps to a specific item by index. The optional second parameter 'smooth' enables smooth scrolling animation instead of an instant jump.
Handling Scroll Events
React to scroll position changes:
@Component({
template: `
<div class="scroll-info">
Scrolled: {{ scrolledIndex }} / {{ items.length }}
</div>
<cdk-virtual-scroll-viewport
itemSize="50"
(scrolledIndexChange)="onScrollIndexChange($event)"
class="viewport">
<div *cdkVirtualFor="let item of items" class="item">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`
})
export class ScrollEventsComponent {
items = Array.from({length: 10000}, (_, i) => ({ name: `Item ${i}` }))
scrolledIndex = 0
onScrollIndexChange(index: number) {
this.scrolledIndex = index
}
}
The scrolledIndexChange event emits whenever the viewport scrolls to a different item. This is useful for tracking position, implementing infinite scroll, or showing progress indicators. The index represents the first fully visible item.
Infinite Scrolling with Virtual Scroll
Load more data as users scroll:
@Component({
template: `
<cdk-virtual-scroll-viewport
itemSize="50"
(scrolledIndexChange)="onScroll($event)"
class="viewport">
<div *cdkVirtualFor="let item of items" class="item">
{{ item.name }}
</div>
<div *ngIf="loading" class="loading">Loading...</div>
</cdk-virtual-scroll-viewport>
`
})
export class InfiniteScrollComponent {
items: any[] = []
loading = false
page = 0
ngOnInit() {
this.loadMore()
}
onScroll(index: number) {
const end = this.viewport.getRenderedRange().end
const total = this.viewport.getDataLength()
if (end === total && !this.loading) {
this.loadMore()
}
}
async loadMore() {
this.loading = true
const newItems = await this.fetchItems(this.page++)
this.items = [...this.items, ...newItems]
this.loading = false
}
async fetchItems(page: number) {
// Simulate API call
return Array.from({length: 50}, (_, i) => ({
name: `Item ${page * 50 + i}`
}))
}
}
This checks if the viewport has scrolled to the end using getRenderedRange().end. When the end is reached and no loading is in progress, it fetches more data. The new items are appended to the existing array, and the viewport automatically adjusts.
Best Practice Note
This is the same virtual scrolling approach we use in CoreUI Angular components to handle large datasets efficiently. Virtual scrolling reduces initial render time from seconds to milliseconds for large lists and keeps memory usage constant regardless of total items. For optimal performance, use trackBy functions with *cdkVirtualFor and avoid complex computations in item templates. For drag-and-drop with virtual scrolling, see our guide on how to drag and drop in Angular with CDK which integrates seamlessly with virtual scroll viewports. Consider using CoreUI’s Angular components which include optimized table and list components with built-in virtualization support.



