Angular's change-detection model turns every framework integration into a two-sided problem: not just "how do I call IntersectionObserver," but "how do I call it without every scroll-driven callback forcing Angular to walk the entire component tree." A well-built directive answers both questions at once, and this guide — part of Framework Integration & Observer Adapters — walks through the exact anatomy of that directive, from @Directive scaffolding to NgZone boundaries to teardown.

Concept Framing

zone.js is what makes Angular's default change detection "just work": it monkey-patches almost every asynchronous browser API — setTimeout, addEventListener, Promise.then, XHR completion — so that when any of them fires, Angular knows a check might be due and schedules one automatically. That model is convenient for click handlers and HTTP responses, which are comparatively rare events. It is actively harmful for IntersectionObserver and ResizeObserver callbacks, which can fire many times per second during a fast scroll or a resize-drag — each firing, left unguarded, triggers a full change-detection pass across the whole tree even when nothing in the template actually needs to re-render.

The fix is not to avoid zone.js (though zoneless Angular removes the problem at the framework level) — it is to be deliberate about which side of the zone boundary an observer's work happens on. NgZone.runOutsideAngular() lets you construct the observer and receive its callback entirely outside Angular's patched execution context; NgZone.run() lets you step back inside for the specific moment you need to update template-bound state. A correctly written directive spends almost all of its time outside the zone and crosses back in only when the view genuinely changes.

The other half of the problem is ownership: a directive is the correct Angular primitive for "attach behavior to a host element" because it does not require a wrapping component, works on any native or custom element, and composes with *ngIf/@if, *ngFor/@for, and structural directives without extra markup. ElementRef gives the directive its native DOM node; @Output() EventEmitter (or the newer output() function) gives the host template a way to react.

Contrast this with wrapping the same logic in a component instead. A component forces every consumer to accept an extra element in the render tree (or ng-content projection gymnastics) just to attach visibility tracking to an existing node — and it duplicates styling concerns the host element already owns. A directive changes nothing about layout or CSS cascade; it only adds behavior. That is precisely the shape observer integration needs: the DOM structure a designer or a UI library already produced stays untouched, and the directive selector is simply added as another attribute alongside existing classes and bindings.

What Belongs in the Directive vs. the Consuming Component

A well-scoped directive owns exactly three responsibilities: constructing the observer, bridging its callback across the zone boundary, and tearing it down. Everything else — deciding what to do when the element becomes visible, choosing an image URL, kicking off an animation — belongs in the component consuming the directive's output, not inside the directive itself. This separation is what makes one IntersectionDirective reusable for lazy-loaded images, scroll-spy navigation highlighting, and viewability analytics simultaneously: the directive only ever emits "here is what the browser observed," never "here is what you should do about it."

Spec / Signature Reference Table

API / Decorator Type Purpose
@Directive({ selector, standalone }) decorator Declares a standalone directive attachable to any host element via selector
ElementRef<HTMLElement> injectable Gives the directive a typed reference to its host's native DOM node
@Output() name = new EventEmitter<T>() class field Classic template-event API; bound in the host template as (name)="handler($event)"
output<T>() function (Angular 17+) Signal-era replacement for EventEmitter; same host template binding syntax
input<T>(default) function (Angular 17+) Signal-based replacement for @Input(); read reactively inside the directive
NgZone.runOutsideAngular(fn) method Executes fn without triggering Angular's zone-patched change-detection scheduling
NgZone.run(fn) method Re-enters the Angular zone so state changes inside fn schedule a change-detection pass
OnInit / OnDestroy lifecycle interfaces ngOnInit() for setup, ngOnDestroy() for guaranteed teardown
DestroyRef injectable (Angular 16+) onDestroy(callback) registers teardown without implementing OnDestroy
takeUntilDestroyed() RxJS operator (Angular 16+) Completes an Observable automatically when the host is destroyed

Step-by-Step Implementation

Step 1 — Scaffold the standalone directive

TypeScript
import { Directive, ElementRef, EventEmitter, NgZone, OnDestroy, OnInit, Output } from '@angular/core';

export interface IntersectionDirectiveOptions {
  threshold?: number | number[];
  rootMargin?: string;
  once?: boolean;
}

@Directive({
  selector: '[appIntersection]',
  standalone: true,
})
export class IntersectionDirective implements OnInit, OnDestroy {
  @Output() appIntersection = new EventEmitter<IntersectionObserverEntry>();

  private observer: IntersectionObserver | null = null;

  constructor(
    private readonly el: ElementRef<HTMLElement>,
    private readonly ngZone: NgZone,
  ) {}

  ngOnInit(): void {
    // Step 2 continues below
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
    this.observer = null;
  }
}

Step 2 — Instantiate outside NgZone

The constructor call and every native callback the browser schedules for this observer must happen outside Angular's zone. Nesting runOutsideAngular() around both the constructor and the observe() call ensures no part of the setup ever crosses back into the zone by accident.

TypeScript
ngOnInit(): void {
  // SSR guard — IntersectionObserver does not exist in Node.js
  if (typeof IntersectionObserver === 'undefined') return;

  this.ngZone.runOutsideAngular(() => {
    this.observer = new IntersectionObserver(
      (entries) => this.handleEntries(entries),
      { threshold: 0, rootMargin: '0px' },
    );
    this.observer.observe(this.el.nativeElement);
  });
}

Step 3 — Re-enter the zone to update state

The callback itself still executes outside the zone (because it was registered from inside runOutsideAngular()). Only the specific work that touches template-bound state needs ngZone.run().

TypeScript
private handleEntries(entries: IntersectionObserverEntry[]): void {
  const [entry] = entries;

  // Re-enter the zone only for the emit — this is what schedules change detection
  this.ngZone.run(() => {
    this.appIntersection.emit(entry);
  });
}

Step 4 — observe() the host element

this.el.nativeElement is the directive's host — whatever DOM element the directive's selector matched in the template. Passing it to observe() is what ties the browser-native observer to this specific Angular-managed element.

TypeScript
// Applied in a template:
// <div appIntersection (appIntersection)="onEntry($event)">Watched content</div>

Step 5 — Disconnect on teardown

ngOnDestroy() (already shown in Step 1) calls disconnect() unconditionally. For directives built as composable functions rather than class bodies, DestroyRef gives the same guarantee without an OnDestroy implementation:

TypeScript
import { DestroyRef, ElementRef, NgZone, Directive, inject } from '@angular/core';

@Directive({ selector: '[appIntersectionFn]', standalone: true })
export class IntersectionFnDirective {
  private readonly el = inject(ElementRef<HTMLElement>);
  private readonly ngZone = inject(NgZone);
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    let observer: IntersectionObserver | null = null;

    if (typeof IntersectionObserver !== 'undefined') {
      this.ngZone.runOutsideAngular(() => {
        observer = new IntersectionObserver(() => { /* … */ });
        observer.observe(this.el.nativeElement);
      });
    }

    this.destroyRef.onDestroy(() => observer?.disconnect());
  }
}

Configuration Variants

@Input options vs signal input()

Both styles feed the same underlying observer options; the choice is purely about which Angular API generation the rest of the codebase uses.

TypeScript
// Decorator style (works in every Angular version since directives existed)
@Input() threshold: number | number[] = 0;
@Input() rootMargin = '0px';

// Signal style (Angular 17+) — read reactively, e.g. inside an effect()
threshold = input<number | number[]>(0);
rootMargin = input<string>('0px');

Once-mode directive

For lazy loading and entry animations, unobserve as soon as the element has crossed the threshold once — the same terminal-state pattern used in the plain-JavaScript IntersectionObserver deep dive, adapted to call unobserve() from inside the directive's own callback:

TypeScript
private handleEntries(entries: IntersectionObserverEntry[]): void {
  const [entry] = entries;
  if (!entry.isIntersecting) return;

  this.ngZone.run(() => this.appIntersection.emit(entry));

  if (this.once) {
    this.observer?.unobserve(entry.target);
  }
}

ResizeObserver variant

The same skeleton — runOutsideAngular constructor, zone re-entry in the callback, disconnect() on teardown — applies unchanged to a resize directive; only the observer type and entry shape differ. For high-frequency resize sequences (drag-resizing a panel, a CSS transition finishing), pair this with the throttling discipline covered in callback throttling and debouncing so the zone re-entry itself doesn't become the bottleneck.

TypeScript
@Directive({ selector: '[appResize]', standalone: true })
export class ResizeDirective implements OnInit, OnDestroy {
  @Output() appResize = new EventEmitter<ResizeObserverEntry>();
  private observer: ResizeObserver | null = null;

  constructor(private el: ElementRef<HTMLElement>, private ngZone: NgZone) {}

  ngOnInit(): void {
    if (typeof ResizeObserver === 'undefined') return;
    this.ngZone.runOutsideAngular(() => {
      this.observer = new ResizeObserver((entries) => {
        this.ngZone.run(() => this.appResize.emit(entries[0]));
      });
      this.observer.observe(this.el.nativeElement, { box: 'content-box' });
    });
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
  }
}

Signals instead of EventEmitter

TypeScript
import { Directive, ElementRef, NgZone, OnDestroy, OnInit, signal } from '@angular/core';

@Directive({ selector: '[appVisibleSignal]', standalone: true, exportAs: 'appVisibleSignal' })
export class VisibleSignalDirective implements OnInit, OnDestroy {
  readonly isVisible = signal(false);
  private observer: IntersectionObserver | null = null;

  constructor(private el: ElementRef<HTMLElement>, private ngZone: NgZone) {}

  ngOnInit(): void {
    if (typeof IntersectionObserver === 'undefined') return;
    this.ngZone.runOutsideAngular(() => {
      this.observer = new IntersectionObserver(([entry]) => {
        // Signal writes schedule change detection on their own in zoneless
        // apps; ngZone.run() keeps zone-based apps consistent too.
        this.ngZone.run(() => this.isVisible.set(entry.isIntersecting));
      });
      this.observer.observe(this.el.nativeElement);
    });
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
  }
}

// Template: <div appVisibleSignal #v="appVisibleSignal">{{ v.isVisible() }}</div>

Edge Cases & Gotchas

Forgetting runOutsideAngular doesn't break correctness — it breaks performance. A directive that constructs the observer without the runOutsideAngular wrapper still works: entries still arrive, the template still updates. The failure is silent and shows up later as sluggish scrolling once a page has thirty or forty of these directives on screen, each one triggering a tree-wide change-detection pass on every threshold crossing. This is the single most common review comment on Angular observer directives.

Forgetting ngZone.run() is the opposite failure. If the callback never re-enters the zone, appIntersection.emit() still fires and any manual subscriber still receives the value — but if the host template reads a plain class property that the callback mutated, Angular's default change-detection strategy may never notice the mutation happened, because zone.js never scheduled a check. The symptom is data that is provably correct in the debugger but never appears on screen until an unrelated click forces a check.

ExpressionChangedAfterItHasBeenCheckedError in development mode. If a directive emits synchronously during the same tick that a parent's ngAfterViewInit() still runs, Angular's dev-mode double-check can catch the discrepancy. IntersectionObserver's mandated initial callback (delivered asynchronously, per the IntersectionObserver deep dive) usually lands after the check completes, but a ResizeObserver observing an element the parent is still laying out can occasionally race it. Defer the state write with a microtask or queueMicrotask if this surfaces in practice.

ng-container and other non-element hosts. ElementRef.nativeElement for a directive placed on <ng-container *ngIf="..."> points at a comment node used purely for Angular's structural bookkeeping — never a real element, and never something IntersectionObserver or ResizeObserver can observe. Move the directive onto an actual element in the template.

SSR and Angular Universal. Server-rendered Angular apps run ngOnInit() on the server, where neither IntersectionObserver nor NgZone's browser-relevant behavior exists in the way the client expects. Guard with typeof IntersectionObserver === 'undefined' (shown throughout this guide) or inject PLATFORM_ID and check isPlatformBrowser() for a more explicit, Angular-idiomatic guard — the same discipline this site's Observer Lifecycle & Memory Management guide recommends for every framework.

Stacking directives on one host. Applying appIntersection and appVisibleSignal to the same element creates two independent IntersectionObserver instances watching the same node — harmless functionally, wasteful in practice. Prefer a single directive that exposes both an EventEmitter and a signal if a host genuinely needs both consumption styles.

NgModule-based legacy applications. Not every Angular codebase has migrated to standalone components. A directive authored with standalone: true can still be consumed from an NgModule-declared component template — Angular treats standalone directives as importable dependencies either way — but it cannot be added to an NgModule's own declarations array. If a legacy module needs the directive available to every component it declares without individually importing it in each standalone component, import the directive into the module's imports array instead of declarations, exactly as you would for another module's exported component.

Framework Integration Patterns

DestroyRef + takeUntilDestroyed() for RxJS-flavored teams. Teams that expose observer state as an Observable (via fromEventPattern wrapping the native callback) can let takeUntilDestroyed() handle unsubscription instead of manually disconnecting in ngOnDestroy(), provided the underlying disconnect() call is still wired into the Observable's teardown logic — takeUntilDestroyed() only stops the subscription, it does not know to call a native API's disconnect() method unless the source observable's teardown function does so itself.

TypeScript
import { DestroyRef, ElementRef, Directive, NgZone, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { fromEventPattern } from 'rxjs';

@Directive({ selector: '[appIntersectionRx]', standalone: true })
export class IntersectionRxDirective {
  private readonly el = inject(ElementRef<HTMLElement>);
  private readonly ngZone = inject(NgZone);
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    if (typeof IntersectionObserver === 'undefined') return;

    let observer: IntersectionObserver;

    const entries$ = fromEventPattern<IntersectionObserverEntry[]>(
      (handler) => {
        this.ngZone.runOutsideAngular(() => {
          observer = new IntersectionObserver((e) => handler(e));
          observer.observe(this.el.nativeElement);
        });
      },
      () => observer?.disconnect(), // teardown function — called on unsubscribe
    );

    entries$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(([entry]) => {
      this.ngZone.run(() => { /* update state */ });
    });
  }
}

Control-flow syntax compatibility. Directives compose transparently with the modern @if / @for block syntax exactly as they did with *ngIf / *ngFor — the directive attaches to whichever element the control-flow block renders, and re-attaches (running ngOnInit again) each time the block re-creates that element.

Angular CDK comparison. The Component Dev Kit ships cdkObserveContent, which wraps MutationObserver for detecting content changes — a related but distinct problem from intersection or resize tracking. If a project already depends on the CDK, model custom intersection/resize directives after its API shape (standalone: true, a directive-scoped service, an Output mirroring the native event) for consistency, but note the CDK does not ship intersection or resize directives itself as of this writing — you still need the ones built in this guide.

Testing Observer Directives

Karma/Jasmine and Jest environments — like every non-browser test runner — have no native IntersectionObserver or ResizeObserver. A directive test suite needs a mock that exposes the same shape the directive expects, plus a way to trigger the callback manually.

TypeScript
// intersection-directive.spec.ts
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IntersectionDirective } from './intersection.directive';

class MockIntersectionObserver implements Partial<IntersectionObserver> {
  static instances: MockIntersectionObserver[] = [];
  callback: IntersectionObserverCallback;

  constructor(callback: IntersectionObserverCallback) {
    this.callback = callback;
    MockIntersectionObserver.instances.push(this);
  }

  observe = jasmine.createSpy('observe');
  unobserve = jasmine.createSpy('unobserve');
  disconnect = jasmine.createSpy('disconnect');

  // Test helper — not part of the real IntersectionObserver interface
  trigger(entries: Partial<IntersectionObserverEntry>[]): void {
    this.callback(entries as IntersectionObserverEntry[], this as unknown as IntersectionObserver);
  }
}

@Component({
  standalone: true,
  imports: [IntersectionDirective],
  template: `<div appIntersection (appIntersection)="onEntry($event)"></div>`,
})
class HostComponent {
  received: IntersectionObserverEntry | null = null;
  onEntry(entry: IntersectionObserverEntry): void {
    this.received = entry;
  }
}

describe('IntersectionDirective', () => {
  let fixture: ComponentFixture<HostComponent>;

  beforeEach(() => {
    MockIntersectionObserver.instances = [];
    (globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver =
      MockIntersectionObserver;
    TestBed.configureTestingModule({ imports: [HostComponent] });
    fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();
  });

  it('emits appIntersection when the mock observer fires', () => {
    const [mock] = MockIntersectionObserver.instances;
    mock.trigger([{ isIntersecting: true, intersectionRatio: 1 } as Partial<IntersectionObserverEntry>]);
    expect(fixture.componentInstance.received?.isIntersecting).toBe(true);
  });

  it('disconnects the native observer when the host is destroyed', () => {
    const [mock] = MockIntersectionObserver.instances;
    fixture.destroy();
    expect(mock.disconnect).toHaveBeenCalled();
  });
});

Two details matter for this test to be meaningful rather than just green: assert disconnect was actually called on destroy (the single most common regression in directive refactors), and trigger the mock callback synchronously rather than relying on the real browser's async initial notification — Angular's TestBed zone handling can otherwise mask a missing ngZone.run() call, since fixture.detectChanges() forces a check regardless of whether the directive scheduled one correctly.

Debugging Checklist

  • Angular DevTools profiler shows a change-detection cycle on every scroll frame. This is the signature of a directive that skipped runOutsideAngular(). Confirm by wrapping the constructor in it and re-profiling — cycle count during scroll should drop to roughly the number of threshold crossings, not the number of scroll frames.
  • Template never updates despite the callback firing (confirmed via console.log or a breakpoint). Confirm the state write happens inside ngZone.run(). If the component uses ChangeDetectionStrategy.OnPush and mutates a plain property instead of a signal or an @Input-derived value, also call ChangeDetectorRef.markForCheck() explicitly inside the zone callback.
  • ExpressionChangedAfterItHasBeenCheckedError in development only. Defer the emit with queueMicrotask or move the initial state read into ngAfterViewInit instead of ngOnInit.
  • Observer silently never fires. Log this.el.nativeElement inside ngOnInit() and confirm it is a real, connected Element — not a comment node from an ng-container host, and not null from a directive applied before the view initialized.
  • Memory grows across route navigations. Verify ngOnDestroy() (or the DestroyRef.onDestroy() callback) actually runs — a common cause of it not running is a directive nested inside a component that itself failed to implement teardown, silently swallowing the destroy signal. Cross-reference with preventing memory leaks in long-running observers for the DevTools heap-snapshot workflow.

The zone-crossing sequence below shows the full lifecycle: setup happens inside the zone, the observer itself lives and fires outside it, and only the state update that the template depends on crosses back in.

NgZone Boundary — Where an Angular Observer Directive Crosses the Zone A two-column diagram. The left column, labeled Inside Angular Zone, shows ngOnInit setup, then appIntersection.emit with change detection, then ngOnDestroy calling observer.disconnect. The right column, labeled Outside Angular Zone, shows the native IntersectionObserver being constructed and its callback firing without triggering change detection. Crimson arrows labeled runOutsideAngular and ngZone.run cross the dashed zone boundary between the columns. Inside Angular Zone Outside Angular Zone (native) ngOnInit() directive setup runOutsideAngular() new IntersectionObserver(callback) native callback fires (no change detection) ngZone.run() appIntersection.emit(entry) change detection runs ngOnDestroy() observer.disconnect() Crimson arrows cross the zone boundary; gray arrows stay within one side. Green box = native scheduling that never touches Angular's change detector.

Frequently Asked Questions

Why should IntersectionObserver callbacks run outside NgZone in an Angular directive?

zone.js patches most asynchronous browser APIs so that any callback re-entering the zone schedules a full component tree change-detection pass. Observer callbacks can fire dozens of times per second during fast scrolling, and most of those firings only touch internal bookkeeping, not template-bound state. Wrapping observer instantiation in NgZone.runOutsideAngular() stops Angular from scheduling a check for every callback; you re-enter the zone with ngZone.run() only on the specific firings that actually need to update the view.

Does zoneless Angular change how observer directives should be written?

Yes, in a simplifying way. Zoneless Angular schedules change detection from signal writes rather than from zone.js patching async callbacks, so there is no zone to escape from and NgZone.runOutsideAngular() becomes a no-op you can safely drop. The directive still needs the same discipline: write the observer's result into a signal (or call markForCheck() if you are on OnPush without signals) so the template actually updates, and still call disconnect() in the teardown hook regardless of which change-detection strategy the app uses.

How do I clean up an Angular observer directive without implementing OnDestroy?

Inject DestroyRef in the directive's constructor and register a callback with destroyRef.onDestroy(() => observer.disconnect()). This achieves the same guaranteed teardown as ngOnDestroy without requiring the class to implement the OnDestroy interface, which is useful when the disconnect logic lives inside a composable function shared across multiple directives rather than inside a single class body.

Can I apply an observer directive to a component that renders an ng-container instead of a host element?

No. ng-container renders as a DOM comment node, not an element, so ElementRef.nativeElement inside a directive attached to it is not something IntersectionObserver or ResizeObserver can observe. Attach the directive to a real host element instead — a wrapping div, or the component's own host element if the component itself renders one — or restructure the template so the observed region has a genuine element node.

Should an observer directive emit values through an EventEmitter or a signal?

Either works, and both are wired the same way from a template. The classic @Output() EventEmitter pattern remains fully supported and is the simplest choice for existing NgModule-based codebases. The output() function paired with an input() or a writable signal is the more idiomatic choice in newer standalone, signals-first codebases because it lets the parent read the latest visibility state reactively without subscribing to an event stream. Pick whichever matches the rest of the component's API surface.


↑ Back to Framework Integration & Observer Adapters