typescript xml scss 145 lines · 4 tabs

Lazy-Load Images with an Angular IntersectionObserver Directive

Shared by codesnips Sep 2026
4 tabs
import { Injectable, NgZone } from '@angular/core';
import { Observable, Subject } from 'rxjs';

export interface ObserverOptions {
  rootMargin?: string;
  threshold?: number | number[];
}

@Injectable({ providedIn: 'root' })
export class IntersectionObserverService {
  private observers = new Map<string, IntersectionObserver>();
  private subjects = new Map<Element, Subject<IntersectionObserverEntry>>();

  constructor(private zone: NgZone) {}

  get supported(): boolean {
    return typeof window !== 'undefined' && 'IntersectionObserver' in window;
  }

  observe(el: Element, options: ObserverOptions = {}): Observable<IntersectionObserverEntry> {
    const subject = new Subject<IntersectionObserverEntry>();
    this.subjects.set(el, subject);
    this.resolveObserver(options).observe(el);
    return subject.asObservable();
  }

  unobserve(el: Element, options: ObserverOptions = {}): void {
    this.resolveObserver(options).unobserve(el);
    const subject = this.subjects.get(el);
    if (subject) {
      subject.complete();
      this.subjects.delete(el);
    }
  }

  private resolveObserver(options: ObserverOptions): IntersectionObserver {
    const key = `${options.rootMargin ?? '0px'}|${options.threshold ?? 0}`;
    let observer = this.observers.get(key);
    if (!observer) {
      observer = new IntersectionObserver(
        entries => this.dispatch(entries),
        { rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0 }
      );
      this.observers.set(key, observer);
    }
    return observer;
  }

  private dispatch(entries: IntersectionObserverEntry[]): void {
    // Re-enter Angular so downstream template bindings are checked.
    this.zone.run(() => {
      for (const entry of entries) {
        this.subjects.get(entry.target)?.next(entry);
      }
    });
  }
}
4 files · typescript, xml, scss Explain with highlit

This snippet shows a reusable Angular attribute directive that defers loading image sources until each image actually scrolls into view, a common technique for reducing initial page weight and improving Largest Contentful Paint on image-heavy lists and grids.

The core idea in LazyImgDirective is to separate the placeholder from the real source. The element renders immediately with a lightweight src (a blur or spinner data URI), while the true URL is bound to a custom [appLazyImg] input and only assigned once the browser reports the element is visible. IntersectionObserver does the visibility bookkeeping natively and off the main layout thread, so no manual scroll listeners or getBoundingClientRect polling are needed.

Instead of every directive instance creating its own observer, IntersectionObserverService maintains a single shared observer per rootMargin/threshold configuration and maps each observed Element back to a Subject. This matters because thousands of individual observers are wasteful, whereas one observer watching many targets is what the API is designed for. Callers observe an element to get an Observable<IntersectionObserverEntry> and unobserve it to release the mapping.

In LazyImgDirective, ngAfterViewInit subscribes to that stream, filters on isIntersecting, and takes only the first hit with take(1) since an image never needs re-loading once fetched. On that event it uses Renderer2 to set the real src and srcset, adds a loaded class, and stops observing. Renderer2 is used rather than touching nativeElement.src directly so the directive stays compatible with server-side rendering and platform abstractions.

A rootMargin of 200px deliberately triggers loading slightly before the image enters view, hiding the network latency so pictures appear already decoded. The takeUntil(this.destroy$) pattern guarantees the subscription is torn down in ngOnDestroy, and the directive also calls unobserve to avoid leaking DOM references. One pitfall worth noting: environments without IntersectionObserver (older browsers, some test runners) need a polyfill or an eager fallback, which the service checks for. The HTML template tab shows the natural usage — a placeholder src plus the lazy binding — demonstrating how little boilerplate the consuming code requires.


Related snips

Share this code

Here's the card — post it anywhere.

Lazy-Load Images with an Angular IntersectionObserver Directive — share card
Link copied