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>
Enter fullscreen mode Exit fullscreen mode
  • The visibility attribute serves as the directive's selector.
  • The visibilityMonitor input is optional; setting it to false instructs the observer to halt monitoring once the element first becomes visible.
  • The visibilityChange output 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;
    };
Enter fullscreen mode Exit fullscreen mode

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>();
}
Enter fullscreen mode Exit fullscreen mode

In the initial implementation, you'll find:

  • The input and output properties mentioned in the usage section.
  • A property named afterViewInit$, which is an Observable acting as a reactive stand-in for the ngAfterViewInit lifecycle hook.
  • A property named observer that will hold the IntersectionObserver instance responsible for tracking the element.
  • A property named isVisibile that 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 {}
Enter fullscreen mode Exit fullscreen mode

Here's the breakdown of the lifecycle logic:

  • The observer is restarted in both ngOnInit and ngOnChanges. This makes the directive responsive to input changes. Note that ngOnChanges is triggered before ngOnInit, but the latter is still necessary because ngOnChanges won't fire if the template doesn't bind to any inputs!
  • The Subject is triggered once the view has been initialized; the details of this are explained shortly.
  • To prevent memory leaks, the observer is disconnected when the directive is destroyed. Additionally, if the host application requests it, an undefined value 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);
    });
  }
Enter fullscreen mode Exit fullscreen mode

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 IntersectionObserver is instantiated with its callback defined. The entries array comprises all monitored elements (in our case, just one). The isIntersecting property 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 visibilityMonitor flag is set to false, 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;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

AccademiaDev

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.

Available courses