Custom Directives — Attribute and Structural Directives

Custom directives are one of Angular’s most powerful but underused features. While Angular ships with built-in directives like *ngIf, *ngFor, and [ngClass], you will regularly encounter UI patterns that need their own reusable DOM behaviour: auto-focusing inputs, detecting clicks outside a dropdown, adding tooltips, enforcing number-only keyboard input, and lazy-loading images as they enter the viewport. Attribute directives modify the behaviour or appearance of existing elements; structural directives add, remove, or repeat DOM elements. This lesson builds practical examples of both types for the task manager application.

Directive Types

Type Purpose Host Binding Examples
Attribute directive Modifies behaviour or appearance of its host element Applied as an attribute: <div appTooltip> appTooltip, appAutoFocus, appClickOutside, appNumberOnly
Structural directive Adds, removes, or repeats DOM elements Applied with * prefix: *appRepeat *appRepeat, *appLetVar, *appRoleGuard
Component directive A directive with a template — the most common kind Applied as a custom element: <app-button> Every @Component is a directive

Directive Decorator Properties

Property Purpose Example
selector CSS selector for the host element '[appTooltip]', 'button[appPrimary]', '.my-class'
standalone Standalone directive — no NgModule needed standalone: true
inputs Declare inputs (alternative to @Input) inputs: ['color: appHighlight']
outputs Declare outputs (alternative to @Output) outputs: ['clickOutside']
host Static host bindings and listeners host: { '(click)': 'onClick()', '[class.active]': 'isActive' }
exportAs Name for template ref: #d="appDirective" exportAs: 'appTooltip'
Note: The @HostListener decorator and @HostBinding decorator are the traditional way to respond to host events and bind host element properties. In Angular 15+, these can be replaced with the host property on the @Directive decorator — host: { '(click)': 'onClick($event)', '[class.active]': 'isActive' }. Both work; the host property is slightly more performant as it is processed at compile time rather than runtime.
Tip: Inject ElementRef to access the directive’s host DOM element and Renderer2 to safely manipulate it. Never use elementRef.nativeElement.style.xxx = yyy directly in production — it does not work with Angular Universal (SSR) and bypasses Angular’s security sanitisation. Use renderer.setStyle(el.nativeElement, 'color', 'red') or renderer.addClass() / renderer.removeClass() instead.
Warning: Structural directives that manipulate the DOM must use TemplateRef and ViewContainerRef — never directly append or remove DOM elements with nativeElement.appendChild(). Using the Angular APIs ensures proper change detection, lifecycle hook execution, and SSR compatibility. Bypassing them produces components that appear to work in development but fail in production SSR or cause memory leaks from destroyed components not being cleaned up.

Complete Directive Examples

// ── 1. Auto-focus directive ───────────────────────────────────────────────
// ng generate directive shared/directives/auto-focus
import { Directive, ElementRef, AfterViewInit, inject, Input } from '@angular/core';

@Directive({
    selector:   '[appAutoFocus]',
    standalone: true,
})
export class AutoFocusDirective implements AfterViewInit {
    private el = inject(ElementRef);

    // Optional delay before focusing (for animation)
    @Input() appAutoFocus: number = 0;

    ngAfterViewInit(): void {
        setTimeout(() => this.el.nativeElement.focus(), this.appAutoFocus);
    }
}
// Usage: <input appAutoFocus>  or  <input [appAutoFocus]="200">

// ── 2. Click-outside directive ────────────────────────────────────────────
import {
    Directive, ElementRef, Output, EventEmitter,
    HostListener, inject,
} from '@angular/core';

@Directive({
    selector:   '[appClickOutside]',
    standalone: true,
})
export class ClickOutsideDirective {
    private el    = inject(ElementRef);
    @Output() appClickOutside = new EventEmitter<void>();

    @HostListener('document:click', ['$event.target'])
    onClick(target: HTMLElement): void {
        if (!this.el.nativeElement.contains(target)) {
            this.appClickOutside.emit();
        }
    }
}
// Usage: <div [appClickOutside]="closeDropdown()">
// Or:    <div (appClickOutside)="isOpen = false">

// ── 3. Tooltip directive ──────────────────────────────────────────────────
import {
    Directive, Input, OnDestroy, Renderer2, ElementRef,
    HostListener, inject,
} from '@angular/core';

@Directive({
    selector:   '[appTooltip]',
    standalone: true,
})
export class TooltipDirective implements OnDestroy {
    private el       = inject(ElementRef);
    private renderer = inject(Renderer2);

    @Input('appTooltip') text = '';
    @Input() tooltipPosition: 'top' | 'bottom' | 'left' | 'right' = 'top';

    private tooltipEl: HTMLElement | null = null;

    @HostListener('mouseenter') onEnter(): void {
        if (!this.text) return;
        this.tooltipEl = this.renderer.createElement('div');
        this.renderer.addClass(this.tooltipEl, 'tooltip');
        this.renderer.addClass(this.tooltipEl, `tooltip--${this.tooltipPosition}`);
        this.renderer.appendChild(this.tooltipEl,
            this.renderer.createText(this.text));
        this.renderer.appendChild(document.body, this.tooltipEl);
        this.positionTooltip();
    }

    @HostListener('mouseleave') onLeave(): void {
        this.removeTooltip();
    }

    ngOnDestroy(): void {
        this.removeTooltip();
    }

    private positionTooltip(): void {
        if (!this.tooltipEl) return;
        const rect = this.el.nativeElement.getBoundingClientRect();
        this.renderer.setStyle(this.tooltipEl, 'position', 'fixed');
        this.renderer.setStyle(this.tooltipEl, 'top', `${rect.top - 36}px`);
        this.renderer.setStyle(this.tooltipEl, 'left',
            `${rect.left + rect.width / 2}px`);
        this.renderer.setStyle(this.tooltipEl, 'transform', 'translateX(-50%)');
        this.renderer.setStyle(this.tooltipEl, 'z-index', '9999');
    }

    private removeTooltip(): void {
        if (this.tooltipEl) {
            this.renderer.removeChild(document.body, this.tooltipEl);
            this.tooltipEl = null;
        }
    }
}
// Usage: <button [appTooltip]="'Delete this task'" tooltipPosition="bottom">

// ── 4. Number-only input directive ────────────────────────────────────────
@Directive({
    selector:   'input[appNumberOnly]',
    standalone: true,
    host: { '(keydown)': 'onKeyDown($event)' },
})
export class NumberOnlyDirective {
    @Input() allowDecimal = false;
    @Input() allowNegative = false;

    onKeyDown(event: KeyboardEvent): void {
        const allowed = ['Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
                         'Home', 'End', 'ArrowLeft', 'ArrowRight'];
        if (allowed.includes(event.key)) return;
        if (event.ctrlKey || event.metaKey) return;  // allow copy/paste/select-all
        if (this.allowDecimal && event.key === '.') return;
        if (this.allowNegative && event.key === '-') return;
        if (!/[0-9]/.test(event.key)) event.preventDefault();
    }
}
// Usage: <input appNumberOnly [allowDecimal]="true">

// ── 5. Lazy image loading directive ──────────────────────────────────────
@Directive({
    selector:   'img[appLazyLoad]',
    standalone: true,
})
export class LazyLoadDirective implements AfterViewInit, OnDestroy {
    private el = inject(ElementRef<HTMLImageElement>);
    @Input('appLazyLoad') src = '';

    private observer: IntersectionObserver | null = null;

    ngAfterViewInit(): void {
        this.observer = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    this.el.nativeElement.src = this.src;
                    this.observer?.unobserve(this.el.nativeElement);
                }
            });
        }, { threshold: 0.1 });
        this.observer.observe(this.el.nativeElement);
    }

    ngOnDestroy(): void { this.observer?.disconnect(); }
}
// Usage: <img appLazyLoad="/api/images/task-1.jpg" alt="Task image">

// ── 6. Custom structural directive — *appRepeat ────────────────────────────
import {
    Directive, Input, TemplateRef, ViewContainerRef,
    OnChanges, SimpleChanges, inject,
} from '@angular/core';

@Directive({
    selector:   '[appRepeat]',
    standalone: true,
})
export class RepeatDirective implements OnChanges {
    private tmpl = inject(TemplateRef<{ $implicit: number; index: number }>);
    private vcr  = inject(ViewContainerRef);

    @Input() appRepeat = 0;

    ngOnChanges(changes: SimpleChanges): void {
        if (!changes['appRepeat']) return;
        this.vcr.clear();
        for (let i = 0; i < this.appRepeat; i++) {
            this.vcr.createEmbeddedView(this.tmpl, { $implicit: i, index: i });
        }
    }
}
// Usage:
// <app-skeleton-card *appRepeat="5; let i = index"></app-skeleton-card>
// Renders 5 skeleton cards while data loads

// ── 7. Role-based structural directive ────────────────────────────────────
@Directive({
    selector:   '[appRole]',
    standalone: true,
})
export class RoleDirective implements OnInit {
    private tmpl    = inject(TemplateRef<any>);
    private vcr     = inject(ViewContainerRef);
    private auth    = inject(AuthStore);

    @Input() appRole: string | string[] = [];

    ngOnInit(): void {
        const roles  = Array.isArray(this.appRole) ? this.appRole : [this.appRole];
        const allowed= roles.some(r => this.auth.hasRole(r));
        if (allowed) {
            this.vcr.createEmbeddedView(this.tmpl);
        }
    }
}
// Usage:
// <button *appRole="'admin'">Delete All</button>
// <button *appRole="['admin', 'manager']">Manage Users</button>

How It Works

Step 1 — Directive Selectors Target Host Elements

A directive’s selector is a CSS selector that Angular matches against the template. '[appTooltip]' matches any element with the appTooltip attribute. 'button[appPrimary]' matches only <button> elements with appPrimary. 'input[type=text]' matches text inputs specifically. When Angular finds a match, it creates the directive class and injects the host element’s ElementRef.

Step 2 — @HostListener Subscribes to Host Element Events

@HostListener('click') adds an event listener to the directive’s host element. @HostListener('document:click', ['$event.target']) listens to the global document click event — useful for click-outside detection. The listener is automatically removed when the directive is destroyed, preventing memory leaks. Angular’s host property option achieves the same result with compile-time processing.

Step 3 — ElementRef and Renderer2 Enable DOM Manipulation

ElementRef.nativeElement is the raw DOM element. Renderer2 provides platform-agnostic DOM manipulation methods: setStyle(), addClass(), removeClass(), createElement(), appendChild(). Using Renderer2 instead of direct DOM manipulation ensures SSR compatibility, web worker compatibility, and respects Angular’s DomSanitizer rules.

Step 4 — Structural Directives Use TemplateRef and ViewContainerRef

When Angular encounters *appRepeat="5", it desugars it to [appRepeat]="5" on a hidden <ng-template> wrapping the element. The directive receives the TemplateRef (the template content) and ViewContainerRef (the location to render). Calling vcr.createEmbeddedView(tmpl) stamps the template into the DOM. vcr.clear() removes all stamped views.

Step 5 — Template Context Passes Data to Structural Directive Templates

vcr.createEmbeddedView(tmpl, { $implicit: i, index: i }) provides a context object to the template. The $implicit property is available as the value when using let x in the template. Named properties are available as let x = propertyName. This is how *ngFor provides let item (the $implicit value), let i = index, and let isFirst = first.

Common Mistakes

Mistake 1 — Direct nativeElement manipulation instead of Renderer2

❌ Wrong — breaks in SSR and bypasses security:

this.el.nativeElement.style.color = 'red';
this.el.nativeElement.classList.add('active');

✅ Correct — use Renderer2:

this.renderer.setStyle(this.el.nativeElement, 'color', 'red');
this.renderer.addClass(this.el.nativeElement, 'active');

Mistake 2 — Forgetting to clean up event listeners in ngOnDestroy

❌ Wrong — IntersectionObserver or external listeners leak after directive destroyed:

ngAfterViewInit(): void {
    this.observer = new IntersectionObserver(...);
    this.observer.observe(this.el.nativeElement);
    // No cleanup — observer runs forever even after component is gone
}

✅ Correct — always disconnect in ngOnDestroy:

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

Mistake 3 — Forgetting to import the directive in standalone component

❌ Wrong — directive not recognised:

@Component({
    standalone: true,
    imports: [CommonModule],  // AutoFocusDirective missing!
    template: `<input appAutoFocus>`
})
// Error: 'appAutoFocus' is not a known property of 'input'

✅ Correct — import the standalone directive:

imports: [CommonModule, AutoFocusDirective, TooltipDirective]

Quick Reference

Task Code
Define attribute directive @Directive({ selector: '[appMyDir]', standalone: true })
Host event listener @HostListener('click', ['$event']) onClick(e) {}
Host binding @HostBinding('class.active') isActive = false
Access host element private el = inject(ElementRef)
Safe DOM manipulation inject(Renderer2).setStyle(el.nativeElement, prop, val)
Structural directive Inject TemplateRef + ViewContainerRef
Render template vcr.createEmbeddedView(tmpl, context)
Clear rendered views vcr.clear()
Use in template *appRepeat="5; let i = index"

🧠 Test Yourself

A ClickOutsideDirective listens on document:click and emits (appClickOutside) when a click occurs outside the host element. Three instances of the directive are active. When the document is clicked, how many @HostListener handlers fire?