Building a Click-Away Directive

There are times when we need to detect that the user has clicked outside a particular element. Dropdowns, modals, and other pieces of UI often rely on this behavior to dismiss themselves. It might also be useful in a media player or a game interface where clicking away should pause playback. Implementing this logic inside each component would lead to duplication and poor reusability. A dedicated directive solves this cleanly.

The directive is responsible for:

  • receiving the host element as its target
  • getting a Renderer2 instance so it can attach event listeners
  • subscribing to every click event on the whole document
  • checking whether the clicked target is outside the host element and, if so, notifying the outside world
  • removing the listener when the directive is torn down

The implementation looks like this:

@Directive({
  selector: '[clickAway]',
  standalone: true,
})
export class ClickOutsideDirective implements OnInit, OnDestroy {
  private readonly elRef: ElementRef<HTMLElement> = inject(
    ElementRef,
  );
  private readonly renderer = inject(Renderer2);
  private readonly document = inject(DOCUMENT);

  @Output() clickAway = new EventEmitter<void>();
  dispose: () => void;

  ngOnInit() {
    this.dispose = this.renderer.listen(
      this.document.body,
      'click',
      (event: MouseEvent) => {
        if (!this.elRef.nativeElement.contains(
          event.target as HTMLElement
        )) {
          this.clickAway.emit();
        }
      }
    );
  }

  ngOnDestroy() {
    this.dispose();
  }
}

The logic is quite simple. The directive pulls in the host element through ElementRef, the event-handling capability through Renderer2, and the global document object via the DOCUMENT token. With those in hand, it registers a listener for every click on the document. When a click lands on an element that is not nested inside the host element, as determined by the Node.contains method, the clickAway event is fired. The listener is cleaned up in ngOnDestroy.

A neat trick here: by naming the EventEmitter exactly the same as the directive's selector, the custom event can be attached to any element seamlessly:

<div (clickAway)="onOutsideClick()">
  <h1>Click outside of me!</h1>
</div>

It behaves as though it were a built-in event like (click) or (mouseover)!

You can try out this live example:

Let us move on to the next scenario.

Listening for Scroll Visibility

Another frequent requirement in web applications is triggering actions, such as fetching additional data, when certain elements appear within the visible area. This is the foundation for infinite scroll, lazy-loaded content, and related patterns. Reusing this behavior across different views calls for the same directive-based approach. The goal is a custom event that fires as soon as an element becomes visible in the viewport.

We will rely on the IntersectionObserver API to handle the visibility checks. While a production implementation might include extra configuration options, this simplified version follows these steps:

  • identify the target element
  • monitor every change in its intersection status
  • emit an event when the element enters the viewport

Here is the resulting directive:

@Directive({
  selector: '[scrollIntoView]',
  standalone: true,
})
export class ScrollIntoViewDirective implements OnInit, OnDestroy {
  @Input() threshold = 0.25;
  @Output() scrollIntoView = new EventEmitter<void>();
  elRef = inject(ElementRef);
  observer: IntersectionObserver;

  ngOnInit() {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            this.scrollIntoView.emit();
          }
        });
      },
      { threshold: this.threshold }
    );

    this.observer.observe(this.elRef.nativeElement);
  }

  ngOnDestroy() {
    this.observer.disconnect();
  }
}

In this case, an IntersectionObserver is instantiated and configured to watch the host element. Every time the observer reports a change, the directive evaluates whether the element is currently intersecting with the viewport. If so, it emits the scrollIntoView event. The observer is disconnected in ngOnDestroy. The overall structure mirrors the ClickAway directive, with the only real difference being the specific logic inside.

Here is how it is used in a template:

<div>
  Really long content goes here
  <div (scrollIntoView)="loadMoreContent()">
    Dynamic content goes here
  </div>
</div>

Like before, this works just like any ordinary native event!

See it in action below:

Note: the demo renders a long collection of div elements; scroll down to the bottom and watch the console for a log message.

Wrapping Up

With every part of this series, new ways to leverage Angular directives come to light. The upcoming installment will explore rendering templates outside the component tree via directives and the concept of Portals. Until then!