Defining the Goal
This walkthrough demonstrates how to build a straightforward Angular directive that monitors whether an element is currently visible within the browser viewport. The directive will emit notifications as elements enter and exit the visible area, offering a practical utility for various UI scenarios.
To accomplish this, we'll rely on the native IntersectionObserver JavaScript API, which is supported across all contemporary browsers.
Intended Usage
The directive is designed to be applied in templates with the following syntax:
<p
visibility
[visibilityMonitor]="true"
(visibilityChange)="onVisibilityChange($event)"
>
I'm being observed! Can you see me yet?
</p>
- The
visibilityattribute serves as the directive's selector. - The
visibilityMonitorinput is optional; setting it tofalseinstructs the observer to halt monitoring once the element first becomes visible. - The
visibilityChangeoutput acts as the event emitter, notifying the parent component of state transitions.
The emitted event will carry an object structured as follows:
type VisibilityChange =
| {
isVisible: true;
target: HTMLElement;
}
| {
isVisible: false;
target: HTMLElement | undefined;
};
An undefined value for the target parameter signals that the element has been removed from the DOM, such as when it's conditionally rendered with @if.
Constructing the Directive
Since this directive only observes and doesn't modify the DOM's structure, it will be implemented as 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 initial implementation, you'll find:
- The
inputandoutputproperties mentioned in the usage section. - A property named
afterViewInit$, which is an Observable acting as a reactive stand-in for thengAfterViewInitlifecycle hook. - A property named
observerthat will hold theIntersectionObserverinstance responsible for tracking the element. - A property named
isVisibilethat stores the most recent visibility state, preventing duplicate emissions of the same status.
As expected, the ElementRef is injected to gain access to the underlying DOM element that the directive is attached to.
Before diving into the core observing logic, let's first wire up the directive's lifecycle management.
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 {}
Here's the breakdown of the lifecycle logic:
- The observer is restarted in both
ngOnInitandngOnChanges. This makes the directive responsive to input changes. Note thatngOnChangesis triggered beforengOnInit, but the latter is still necessary becausengOnChangeswon't fire if the template doesn't bind to any inputs! - The
Subjectis triggered once the view has been initialized; the details of this are explained shortly. - To prevent memory leaks, the
observeris disconnected when the directive is destroyed. Additionally, if the host application requests it, anundefinedvalue is emitted to signal the element's removal from the DOM.
Understanding the IntersectionObserver
This is the core mechanism driving our directive. The reconnectObserver method is responsible for initiating observation and looks 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);
});
}
While it might seem intricate at first glance, the logic is quite straightforward:
- The process kicks off by disconnecting any previously active observer.
- A new
IntersectionObserveris instantiated with its callback defined. Theentriesarray comprises all monitored elements (in our case, just one). TheisIntersectingproperty reveals whether the element's visibility status has shifted. This new state is compared with the previous one stored in our property; if different, a new value is emitted. The current state is then saved back to the property for future reference. - If the
visibilityMonitorflag is set tofalse, the observer's task ends the moment the element becomes visible, and it's disconnected immediately. - Finally, the observer is activated by passing in the element. Since we need the view to be ready, this step is deferred until after view initialization.
To wrap up, here's the simple method for disconnecting the observer:
private disconnectObserver(): void {
if (this.observer) {
this.observer.disconnect();
this.observer = undefined;
}
}
Complete Implementation
Below is the finished directive. Remember, this was an exercise in exploring concepts, so don't hesitate to adapt and modify it to suit your specific needs!
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;
}
}
}
AccademiaDev: text-based web development courses!
My philosophy centers on delivering concise, high-value educational material that avoids the unnecessary padding often found in traditional textbooks. These interactive online courses, informed by my experience as a consultant and trainer, offer practical knowledge through a blend of text, code samples, and quizzes, ensuring an efficient and engaging learning journey.


