Using the Intersection Observer API with Angular
This article shows how to build a directive with Angular that uses the Intersection Observer API to check when an element becomes visible on the page
Intersection Observer: what is it?
The Intersection Observer
API is a relatively new functionality in web browsers that can allows us to listen to events when an element’s visibility on the page changes.
Thanks to Angular's directives, we can create a reusable way to use this API in a declarative and easy way.
Use-Cases of the Intersection Observer
Why would you want to use this API?
- Improving performance by instantiating only the objects that that are visible to the users
- Improving efficiency by only loading the assets (images, fonts, etc.) used by the visible items
- Implement parallax effects and much, much more
Personally, I'm very intrigued by the possible performance and efficiency gains enabled by the API - which is what led me to make this directive in the first place.
Debouncing
The directive
we will be using will also enable an important factor not supported by the API: debouncing.
Imagine your user is scrolling a list very quickly, and your Intersection Observer
emits the element as "visible". Was it, though?
In my specific scenario, I was loading fonts from Google Fonts when an item was visible for more than 250ms: that tells me the user may have stopped scrolling.
Creating a directive that uses an Intersection Observer
Let's create this directive, step by step. We will call this directive ObserveVisibilityDirective
.
Inputs & Outputs
First, we define our inputs and outputs:
@Input() debounceTime = 0;
@Input() threshold = 1;
@Output() visible = new EventEmitter<HTMLElement>();
debounceTime
: we've already introduced what debounceTime is, but you should know that by default it is 0, as it is how the API would workthreshold
: the threshold property indicates at what percentage the callback should be executed. By default, right away.visible
: we will use the output to notify the parent when an element is visible
Creating the Observer
First, we save an instance of the Observer on our class:
private observer: IntersectionObserver | undefined;
Additionally, we also want to create an instance of a Subject which will act as an intermediate messaging bus which we will use to debounce the items' emissions:
private subject$ = new Subject<{
entry: IntersectionObserverEntry;
observer: IntersectionObserver;
}>();
When the component is initialized, we instantiate the Observer.
The following will:
- instantiate the
Observer
- for each items called in the callback, we check if the item is "intersecting"
- if yes - then emit the entry via the
Subject
private createObserver() {
const options = {
rootMargin: '0px',
threshold: this.threshold,
};
const isIntersecting = (entry: IntersectionObserverEntry) =>
entry.isIntersecting || entry.intersectionRatio > 0;
this.observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (isIntersecting(entry)) {
this.subject$.next({ entry, observer });
}
});
}, options);
}
When the view is initialized, we call the method startObservingElements
.
- We use the observer to observe the
host element
of the directive - We subscribe to the
Subject
and delay the emission using thedebounceTime
input's value - When the Subject emits, we check again if the element is visible using a one-off Intersection Observer promise
- If the element is still visible, then we emit the Output and notify the listeners that the element is visible and has been visible for the specified
debounceTime
time
private isVisible(element: HTMLElement) {
return new Promise(resolve => {
const observer = new IntersectionObserver(([entry]) => {
resolve(entry.intersectionRatio === 1);
observer.disconnect();
});
observer.observe(element);
});
}
private startObservingElements() {
if (!this.observer) {
return;
}
this.observer.observe(this.element.nativeElement);
this.subject$
.pipe(delay(this.debounceTime), filter(Boolean))
.subscribe(async ({ entry, observer }) => {
const target = entry.target as HTMLElement;
const isStillVisible = await this.isVisible(target);
if (isStillVisible) {
this.visible.emit(target);
observer.unobserve(target);
}
});
}
Using the directive
Here's an example of how you could use the directive with a very very long list.
The visible
output will tell you when the item is finally visible for at least 300ms - which could mean the user isn't scrolling anymore. In some cases you may want this number to be lower.
<ng-container *ngFor="let item of longList">
<app-item
observeVisibility
[debounceTime]="300"
(visible)="onVisible(font)"
></app-item>
</ng-container>
The full directive's code
Here's the full source code of the Directive:
@Directive({
selector: '[observeVisibility]',
})
export class ObserveVisibilityDirective
implements OnDestroy, OnInit, AfterViewInit {
@Input() debounceTime = 0;
@Input() threshold = 1;
@Output() visible = new EventEmitter<HTMLElement>();
private observer: IntersectionObserver | undefined;
private subject$ = new Subject<{
entry: IntersectionObserverEntry;
observer: IntersectionObserver;
}>();
constructor(private element: ElementRef) {}
ngOnInit() {
this.createObserver();
}
ngAfterViewInit() {
this.startObservingElements();
}
ngOnDestroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = undefined;
}
this.subject$.next();
this.subject$.complete();
}
private isVisible(element: HTMLElement) {
return new Promise(resolve => {
const observer = new IntersectionObserver(([entry]) => {
resolve(entry.intersectionRatio === 1);
observer.disconnect();
});
observer.observe(element);
});
}
private createObserver() {
const options = {
rootMargin: '0px',
threshold: this.threshold,
};
const isIntersecting = (entry: IntersectionObserverEntry) =>
entry.isIntersecting || entry.intersectionRatio > 0;
this.observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (isIntersecting(entry)) {
this.subject$.next({ entry, observer });
}
});
}, options);
}
private startObservingElements() {
if (!this.observer) {
return;
}
this.observer.observe(this.element.nativeElement);
this.subject$
.pipe(delay(this.debounceTime), filter(Boolean))
.subscribe(async ({ entry, observer }) => {
const target = entry.target as HTMLElement;
const isStillVisible = await this.isVisible(target);
if (isStillVisible) {
this.visible.emit(target);
observer.unobserve(target);
}
});
}
}
Thank you for reading, I hope you enjoyed this article. If you did, consider follow me on Twitter or sign up to the Newsletter using the form below!