
Ever wanted to trigger animations only when elements appear in the viewport? Or maybe load images and content just before they’re visible? That’s where the Intersection Observer API shines — and Angular makes it super smooth to integrate.
In this post, I’ll walk you through how I implemented a directive-based solution in Angular to detect visibility changes — ideal for lazy loading, infinite scrolls, or scroll-triggered animations.
⚡ What’s the Intersection Observer API?
Instead of attaching heavy scroll
or resize
event listeners, Intersection Observer gives you a cleaner, more performant way to monitor when DOM elements enter or exit the viewport (or a scrollable container).
✅ Why Use It?
- Better Performance: No janky scroll listeners.
- Asynchronous + Efficient: Observers run in the background.
- Configurable: Thresholds, margins, and custom containers supported.
🧱 My Angular Setup
Let’s dive into how I built a reusable Angular directive that lets me observe visibility changes for any component or element.
🧾 Step 1: HTML Template (app.component.html
)
<div style="display: flex; overflow: scroll" #el>
<div
*ngFor="let item of items"
class="row"
[appIntersection]
(viewChange)="handleViewChange($event, item)"
>
{{ item }}
</div>
</div>
<div style="position: fixed; bottom: 5rem">
<span *ngFor="let it of itemsInView">{{ it }} - </span>
</div>
🎨 Step 2: Styling (app.component.css
)
.row {
height: 500px;
min-width: 500px;
margin: 1rem;
padding: 1rem;
background-color: rebeccapurple;
color: white;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
🧠 Step 3: Component Logic (app.component.ts
)
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
items = Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`);
itemsInView = new Set<string>();
handleViewChange(event: { isIntersecting: boolean }, item: string) {
if (event.isIntersecting) {
this.itemsInView.add(item);
} else {
this.itemsInView.delete(item);
}
}
}
🧩 Step 4: Custom Directive (intersection.directive.ts
)
import {
Directive,
ElementRef,
Output,
EventEmitter,
Input,
AfterViewInit,
OnDestroy
} from '@angular/core';
@Directive({
selector: '[appIntersection]',
})
export class IntersectionDirective implements AfterViewInit, OnDestroy {
@Input() appIntersection: IntersectionObserverInit = {};
@Output() viewChange = new EventEmitter();
private observer!: IntersectionObserver;
constructor(private el: ElementRef) {}
ngAfterViewInit() {
this.observer = new IntersectionObserver((entries) => {
const entry = entries[0];
this.viewChange.emit({
isIntersecting: entry.isIntersecting,
intersectionRatio: entry.intersectionRatio,
});
}, this.appIntersection);
this.observer.observe(this.el.nativeElement);
}
ngOnDestroy() {
this.observer.unobserve(this.el.nativeElement);
this.observer.disconnect();
}
}
⚙️ How This Works
- The directive hooks into the DOM element.
- The
IntersectionObserver
watches for visibility changes. - On intersection, we emit a custom event (
viewChange
). - The component listens and updates a set of “visible” items in real time.
🔄 Use Cases You’ll Love
- 📷 Lazy Load Images: Only load what the user is about to see.
- 📜 Infinite Scroll: Fetch more data as needed.
- ✨ Animate On Scroll: Trigger CSS or JS animations when content enters view.
- 🧪 A/B Testing UX: Monitor which parts of your UI users actually reach.
✅ Final Thoughts
Integrating the Intersection Observer API in Angular is super clean when paired with a directive-based approach. You get performance gains, a modular structure, and total control over how elements react to visibility — without the headaches of scroll tracking.
Start small, modularize the logic, and you’ll find dozens of clever ways to use this.