In this article I'm going to illustrate how to create a very simple Angular Directive that keeps track of an element's visibility state, or in other words, when it goes in and out of the viewport. I hope this will be a nice and perhaps useful exercise!
In order to do this, we're going to use the IntersectionObserver
JavaScript API which is available in modern browsers.
What we want to achieve
We want to use the Directive like this:
<p
visibility
[visibilityMonitor]="true"
(visibilityChange)="onVisibilityChange($event)"
>
I'm being observed! Can you see me yet?
</p>
-
visibility
is the selector of our custom directive -
visibilityMonitor
is an optional input which specifies whether or not to keep observing the element (iffalse
, stop monitoring when it enters the viewport) -
visibilityChange
will notifies us
The output will be of this shape:
type VisibilityChange =
| {
isVisible: true;
target: HTMLElement;
}
| {
isVisible: false;
target: HTMLElement | undefined;
};
Having an undefined target
will mean that the element has been removed from the DOM (for example, by an @if
).
Creation of the Directive
Our directive will simply monitor an element, it will not change the DOM structure: it will be an Attribute Directive.
@Directive({
selector: "[visibility]",
standalone: true
})
export class VisibilityDirective implements OnInit, OnChanges, AfterViewInit, OnDestroy {
private element = inject(ElementRef);
/**
* Emits after the view is initialized.
*/
private afterViewInit$ = new Subject<void>();
/**
* The IntersectionObserver for this element.
*/
private observer: IntersectionObserver | undefined;
/**
* Last known visibility for this element.
* Initially, we don't know.
*/
private isVisible: boolean = undefined;
/**
* If false, once the element becomes visible there will be one emission and then nothing.
* If true, the directive continuously listens to the element and emits whenever it becomes visible or not visible.
*/
visibilityMonitor = input(false);
/**
* Notifies the listener when the element has become visible.
* If "visibilityMonitor" is true, it continuously notifies the listener when the element goes in/out of view.
*/
visibilityChange = output<VisibilityChange>();
}
In the code above you see:
- the
input
andoutput
we talked about earlier - a property called
afterViewInit$
(an Observable) which will act as a reactive counterpart to thengAfterViewInit
lifecycle hook - a property called
observer
which will store theIntersectionObserver
in charge of monitoring our element - a property called
isVisibile
which will store the last visibility state, in order to avoid re-emitting the same state twice in a row
And naturally, we inject the ElementRef
in order to grab the DOM element on which we apply our directive.
Before writing the main method, let's take care of the lifecycle of the directive.
ngOnInit(): void {
this.reconnectObserver();
}
ngOnChanges(): void {
this.reconnectObserver();
}
ngAfterViewInit(): void {
this.afterViewInit$.next();
}
ngOnDestroy(): void {
// Disconnect and if visibilityMonitor is true, notify the listener
this.disconnectObserver();
if (this.visibilityMonitor) {
this.visibilityChange.emit({
isVisible: false,
target: undefined
});
}
}
private reconnectObserver(): void {}
private disconnectObserver(): void {}
Now here's what happens:
- Inside both
ngOnInit
andngOnChanges
we restart the observer. This is in order to make the directive reactive: if the input changes, the directive will start behaving differently. Notice that, even ifngOnChanges
also runs beforengOnInit
, we still needngOnInit
becausengOnChanges
doesn't run if there are no inputs in the template! - When the view is initialized we trigger the
Subject
, we'll get to this in a few seconds - We disconnect our
observer
when the directive is destroyed in order to avoid memory leaks. Lastly, if the developer asked for it, we notify that the element has been removed from the DOM by emitting anundefined
element.
IntersectionObserver
This is the heart of our directive. Our reconnectObserver
method will be the one to start observing! It'll be something like this:
private reconnectObserver(): void {
// Disconnect an existing observer
this.disconnectObserver();
// Sets up a new observer
this.observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
const { isIntersecting: isVisible, target } = entry;
const hasChangedVisibility = isVisible !== this.isVisible;
const shouldEmit = isVisible || (!isVisible && this.visibilityMonitor);
if (hasChangedVisibility && shouldEmit) {
this.visibilityChange.emit({
isVisible,
target: target as HTMLElement
});
this.isVisible = isVisible;
}
// If visilibilyMonitor is false, once the element is visible we stop.
if (isVisible && !this.visibilityMonitor) {
observer.disconnect();
}
});
});
// Start observing once the view is initialized
this.afterViewInit$.subscribe(() => {
this.observer?.observe(this.element.nativeElement);
});
}
Trust me, it's not as complicated as it seems! Here's the mechanism:
- First we disconnect the observer if it was already running
- We create an
IntersectionObserver
and define its behavior. Theentries
will contain the monitored elements, so it will contain our element. The propertyisIntersecting
will indicate if the element's visibility has changed: we compare it to the previous state (our property) and if it's due, we emit. Then we store the new state in our property for later. - If
visibilityMonitor
isfalse
, as soon as the element becomes visible we disconnect the observer: its job is done! - Then we have to start the observer by passing our element, so we wait for our view to be initialized in order to do that.
Lastly, let's implement the method which disconnects the observer, easy peasy:
private disconnectObserver(): void {
if (this.observer) {
this.observer.disconnect();
this.observer = undefined;
}
}
Final code
Here's the full directive. This was just an exercise, so be free to change it to whatever you like!
type VisibilityChange =
| {
isVisible: true;
target: HTMLElement;
}
| {
isVisible: false;
target: HTMLElement | undefined;
};
@Directive({
selector: "[visibility]",
standalone: true
})
export class VisibilityDirective
implements OnChanges, OnInit, AfterViewInit, OnDestroy {
private element = inject(ElementRef);
/**
* Emits after the view is initialized.
*/
private afterViewInit$ = new Subject<void>();
/**
* The IntersectionObserver for this element.
*/
private observer: IntersectionObserver | undefined;
/**
* Last known visibility for this element.
* Initially, we don't know.
*/
private isVisible: boolean = undefined;
/**
* If false, once the element becomes visible there will be one emission and then nothing.
* If true, the directive continuously listens to the element and emits whenever it becomes visible or not visible.
*/
visibilityMonitor = input(false);
/**
* Notifies the listener when the element has become visible.
* If "visibilityMonitor" is true, it continuously notifies the listener when the element goes in/out of view.
*/
visibilityChange = output<VisibilityChange>();
ngOnInit(): void {
this.reconnectObserver();
}
ngOnChanges(): void {
this.reconnectObserver();
}
ngAfterViewInit(): void {
this.afterViewInit$.next(true);
}
ngOnDestroy(): void {
// Disconnect and if visibilityMonitor is true, notify the listener
this.disconnectObserver();
if (this.visibilityMonitor) {
this.visibilityChange.emit({
isVisible: false,
target: undefined
});
}
}
private reconnectObserver(): void {
// Disconnect an existing observer
this.disconnectObserver();
// Sets up a new observer
this.observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
const { isIntersecting: isVisible, target } = entry;
const hasChangedVisibility = isVisible !== this.isVisible;
const shouldEmit = isVisible || (!isVisible && this.visibilityMonitor);
if (hasChangedVisibility && shouldEmit) {
this.visibilityChange.emit({
isVisible,
target: target as HTMLElement
});
this.isVisible = isVisible;
}
// If visilibilyMonitor is false, once the element is visible we stop.
if (isVisible && !this.visibilityMonitor) {
observer.disconnect();
}
});
});
// Start observing once the view is initialized
this.afterViewInit$.subscribe(() => {
this.observer?.observe(this.element.nativeElement);
});
}
private disconnectObserver(): void {
if (this.observer) {
this.observer.disconnect();
this.observer = undefined;
}
}
}
Top comments (1)
Hi Michele Stieven ,
Thanks for sharing.