Custom attribute directives encapsulate reusable DOM behaviours that can be attached to any element. A ClickOutsideDirective detects clicks outside the host element (for closing dropdowns and modals). A LazyLoadImageDirective uses IntersectionObserver to defer image loading until the image is near the viewport. These behaviours would otherwise be duplicated across every component that needs them. Directives extract the DOM behaviour while keeping components focused on their own logic.
Custom Attribute Directive
import { Directive, Output, EventEmitter, HostListener, ElementRef, inject } from '@angular/core';
// ── ClickOutside Directive ────────────────────────────────────────────────
// Usage: <div appClickOutside (clickedOutside)="closeMenu()">...</div>
@Directive({
selector: '[appClickOutside]',
standalone: true,
})
export class ClickOutsideDirective {
private elementRef = inject(ElementRef);
@Output() clickedOutside = new EventEmitter<void>();
// Listen on the document, not the host element
@HostListener('document:click', ['$event.target'])
onDocumentClick(target: HTMLElement): void {
const clickedInside = this.elementRef.nativeElement.contains(target);
if (!clickedInside) {
this.clickedOutside.emit();
}
}
}
// ── Lazy Load Image Directive ────────────────────────────────────────────
// Usage: <img appLazyLoad [src]="imageUrl" />
// (swap with a placeholder until the image enters the viewport)
@Directive({
selector: 'img[appLazyLoad]',
standalone: true,
host: {
'[attr.loading]': '"lazy"', // native lazy loading as fallback
},
})
export class LazyLoadImageDirective implements OnInit, OnDestroy {
private el = inject(ElementRef<HTMLImageElement>);
private observer?: IntersectionObserver;
private renderer = inject(Renderer2);
ngOnInit(): void {
// Replace actual src with placeholder until in viewport
const actualSrc = this.el.nativeElement.getAttribute('src') ?? '';
this.renderer.setAttribute(this.el.nativeElement, 'src', '/assets/placeholder.svg');
this.renderer.setAttribute(this.el.nativeElement, 'data-src', actualSrc);
this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const dataSrc = this.el.nativeElement.getAttribute('data-src');
if (dataSrc) {
this.renderer.setAttribute(this.el.nativeElement, 'src', dataSrc);
}
this.observer?.disconnect();
}
});
}, { threshold: 0.1 });
this.observer.observe(this.el.nativeElement);
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
}
Renderer2 instead of direct DOM manipulation (element.setAttribute()) inside directives. Renderer2 abstracts over the DOM, making the directive work in server-side rendering (Angular Universal) where document does not exist. Direct DOM access (this.el.nativeElement.style.color = 'red') works in the browser but fails in SSR. Renderer2.setStyle(), renderer.addClass(), and renderer.setAttribute() work in all Angular rendering environments.ngOnDestroy. These observers hold references to DOM elements and callbacks — if not disconnected, they prevent garbage collection of the directive and its associated DOM elements. Always call observer.disconnect(), observer.unobserve(element), or subscription.unsubscribe() in ngOnDestroy for any resource that a directive acquires in ngOnInit.@HostListener('document:click') — this attaches a click listener to the entire document. If 20 dropdown components each have a ClickOutsideDirective, that is 20 document-level click listeners running on every click anywhere in the page. For high-frequency events or many instances, consider using event delegation at the document level (one listener that checks which directives are active) rather than a listener per directive. For typical usage (1–5 active dropdowns), individual listeners are acceptable.Common Mistakes
Mistake 1 — Direct DOM manipulation instead of Renderer2 (breaks SSR)
❌ Wrong — this.el.nativeElement.style.color = 'red'; works in browser but breaks SSR.
✅ Correct — this.renderer.setStyle(this.el.nativeElement, 'color', 'red').
Mistake 2 — Not cleaning up observers in ngOnDestroy (memory leak)
❌ Wrong — IntersectionObserver created in ngOnInit but never disconnected; DOM element never GC’d.
✅ Correct — ngOnDestroy() { this.observer?.disconnect(); }.