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
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.
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().
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.
// 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:
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.
// 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:
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.
@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
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.
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.
// 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.logor a breakpoint). Confirm the state write happens insidengZone.run(). If the component usesChangeDetectionStrategy.OnPushand mutates a plain property instead of a signal or an@Input-derived value, also callChangeDetectorRef.markForCheck()explicitly inside the zone callback. ExpressionChangedAfterItHasBeenCheckedErrorin development only. Defer the emit withqueueMicrotaskor move the initial state read intongAfterViewInitinstead ofngOnInit.- Observer silently never fires. Log
this.el.nativeElementinsidengOnInit()and confirm it is a real, connectedElement— not a comment node from anng-containerhost, and notnullfrom a directive applied before the view initialized. - Memory grows across route navigations. Verify
ngOnDestroy()(or theDestroyRef.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.
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.