In modern web applications, especially those displaying large feeds of data like social media timelines, e-commerce product listings, or news articles, providing a smooth user experience is critical. The “load more” button is a thing of the past. Today’s users expect content to load seamlessly as they scroll. This is where infinite scroll comes in.
As of 2025, Angular has evolved significantly, with standalone components and signals becoming the standard for building clean, reactive, and performant applications. This guide will walk you through a modern, step-by-step approach to implementing infinite scroll in Angular using these latest features. We’ll use the highly efficient Intersection Observer API to detect when the user reaches the end of the list, providing a robust solution for your projects.
Let’s get started!
Prerequisites
- Basic understanding of Angular and TypeScript.
- Familiarity with Angular Signals.
- Angular CLI installed on your machine.
- Node.js and npm/yarn.
Step 1: Setting Up Your Angular Project
First, let’s create a new standalone Angular application. Open your terminal and run:
Bash
ng new angular-infinite-scroll-2025 --standalone
cd angular-infinite-scroll-2025
We don’t need any special libraries beyond what Angular provides, as the Intersection Observer is a native browser API.
Step 2: Creating a Mock Data Service
To simulate fetching data from a server API, we’ll create a simple service that returns paginated data.
Generate a service using the Angular CLI:
Bash
ng generate service services/data
Now, open src/app/services/data.service.ts
and add the following code. This service will mimic a network delay and return a new batch of items for each “page.”
TypeScript
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
export interface Item {
id: number;
name: string;
}
@Injectable({
providedIn: 'root',
})
export class DataService {
private allItems: Item[] = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `Item #${i + 1}`,
}));
getItems(page: number, limit: number): Observable<Item[]> {
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const items = this.allItems.slice(startIndex, endIndex);
// Simulate a network delay of 500ms
return of(items).pipe(delay(500));
}
}
Step 3: Building the List Component
Next, let’s create the component that will display our list and handle the infinite scroll logic.
Bash
ng generate component components/item-list --standalone
Now, let’s update the component’s TypeScript file at src/app/components/item-list/item-list.component.ts
. Here, we’ll use signals to manage our state (the list of items, current page, and loading status).
TypeScript
import { Component, OnInit, ElementRef, ViewChild, OnDestroy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService, Item } from '../../services/data.service';
@Component({
selector: 'app-item-list',
standalone: true,
imports: [CommonModule],
templateUrl: './item-list.component.html',
styleUrls: ['./item-list.component.css'],
})
export class ItemListComponent implements OnInit, OnDestroy {
@ViewChild('sentinel', { static: true }) sentinel!: ElementRef;
items = signal<Item[]>([]);
isLoading = signal(false);
currentPage = signal(1);
private observer!: IntersectionObserver;
private readonly ITEMS_PER_PAGE = 20;
constructor(private dataService: DataService) {}
ngOnInit(): void {
this.loadItems();
this.setupIntersectionObserver();
}
ngOnDestroy(): void {
if (this.observer) {
this.observer.disconnect();
}
}
private setupIntersectionObserver(): void {
const options = {
root: null, // viewport
rootMargin: '0px',
threshold: 1.0, // trigger when the sentinel is fully visible
};
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading()) {
this.loadItems();
}
}, options);
this.observer.observe(this.sentinel.nativeElement);
}
loadItems(): void {
this.isLoading.set(true);
this.dataService.getItems(this.currentPage(), this.ITEMS_PER_PAGE).subscribe((newItems) => {
if (newItems.length > 0) {
this.items.update(currentItems => [...currentItems, ...newItems]);
this.currentPage.update(page => page + 1);
} else {
// No more items to load, disconnect the observer
this.observer.disconnect();
}
this.isLoading.set(false);
});
}
}
Key parts of this code:
- Signals:
items
,isLoading
, andcurrentPage
are signals, making state management reactive and straightforward. - @ViewChild(‘sentinel’): We get a reference to an empty
div
at the end of our list. This is our trigger element. - IntersectionObserver: In
setupIntersectionObserver
, we create an observer that watches the sentinel. When the sentinel becomes visible in the viewport, it calls ourloadItems
function. This is far more performant than listening to scroll events. - ngOnDestroy: It’s crucial to disconnect the observer when the component is destroyed to prevent memory leaks.
Step 4: Creating the Template and Sentinel
Now, let’s create the HTML for our component in src/app/components/item-list/item-list.component.html
.
HTML
<div class="list-container">
@for (item of items(); track item.id) {
<div class="item">
{{ item.name }}
</div>
}
@if (isLoading()) {
<div class="loading">Loading more items...</div>
}
<div #sentinel class="sentinel"></div>
</div>
Add some basic styling in src/app/components/item-list/item-list.component.css
to make it look decent.
CSS
.list-container {
max-width: 600px;
margin: 20px auto;
border: 1px solid #ccc;
padding: 10px;
border-radius: 8px;
height: 500px; /* Important: The container needs a fixed height to be scrollable */
overflow-y: auto;
}
.item {
padding: 20px;
border-bottom: 1px solid #eee;
font-size: 1.2rem;
text-align: center;
}
.item:last-child {
border-bottom: none;
}
.loading {
padding: 20px;
text-align: center;
color: #888;
font-style: italic;
}
.sentinel {
height: 1px; /* The sentinel needs some height to be observed */
}
Step 5: Putting It All Together
Finally, let’s add our item-list
component to our main app component. Open src/app/app.component.ts
and import it.
TypeScript
import { Component } from '@angular/core';
import { ItemListComponent } from './components/item-list/item-list.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ItemListComponent], // Import the component here
template: `
<main>
<h1>Angular Infinite Scroll 2025</h1>
<app-item-list></app-item-list>
</main>
`,
styles: `
main {
font-family: sans-serif;
text-align: center;
}
`,
})
export class AppComponent {}
Now, run your application:
Bash
ng serve
Open your browser to http://localhost:4200
, and you’ll see a list of items. As you scroll to the bottom, a loading indicator will appear, and new items will be fetched and added to the list automatically.
Conclusion
You have successfully implemented a modern, performant infinite scroll in Angular for 2025! By leveraging standalone components, signals for state management, and the browser’s native Intersection Observer API, you’ve created a solution that is both efficient and provides an excellent user experience. This pattern is perfect for any application that needs to display a large, dynamically growing set of data.
Video
Deep Research
Canvas
Image
Gemini can make mistakes, so double-check it