Skip to content

Custom Directives 🎯

Directives are classes that add behavior to elements in your Angular applications. While Angular provides built-in directives, custom directives let you create reusable behaviors tailored to your needs.

Change the appearance or behavior of an element, component, or another directive.

Change the DOM layout by adding or removing elements (less common with modern @if, @for).

Compose directives together for powerful reusability.

import { Directive, ElementRef, inject } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true
})
export class HighlightDirective {
private el = inject(ElementRef);
constructor() {
this.el.nativeElement.style.backgroundColor = 'yellow';
}
}

Usage:

<p appHighlight>This text will be highlighted!</p>
import { Directive, ElementRef, input, effect, inject } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true
})
export class HighlightDirective {
private el = inject(ElementRef);
// Modern input signal
appHighlight = input<string>('yellow');
constructor() {
effect(() => {
this.el.nativeElement.style.backgroundColor = this.appHighlight();
});
}
}

Usage:

<p appHighlight="lightblue">Custom color highlight</p>
<p [appHighlight]="dynamicColor">Dynamic color</p>
import { Directive, ElementRef, input, inject } from '@angular/core';
@Directive({
selector: '[appHoverHighlight]',
standalone: true,
host: {
'(mouseenter)': 'onMouseEnter()',
'(mouseleave)': 'onMouseLeave()'
}
})
export class HoverHighlightDirective {
private el = inject(ElementRef);
highlightColor = input<string>('yellow');
defaultColor = input<string>('transparent');
constructor() {
this.el.nativeElement.style.backgroundColor = this.defaultColor();
}
onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.highlightColor();
}
onMouseLeave() {
this.el.nativeElement.style.backgroundColor = this.defaultColor();
}
}

Usage:

<div appHoverHighlight [highlightColor]="'lightgreen'">
Hover over me!
</div>

Warning: For Angular versions 19 and above, you can remove the standalone: true property from your dynamic component directives.

import { Directive, ElementRef, output, inject } from '@angular/core';
@Directive({
selector: '[appClickOutside]',
standalone: true,
host: {
'(document:click)': 'onClick($event)'
}
})
export class ClickOutsideDirective {
private el = inject(ElementRef);
clickOutside = output<void>();
onClick(event: MouseEvent) {
const clickedInside = this.el.nativeElement.contains(event.target);
if (!clickedInside) {
this.clickOutside.emit();
}
}
}

Usage:

@Component({
selector: 'app-dropdown',
standalone: true,
imports: [ClickOutsideDirective],
template: `
<div class="dropdown" appClickOutside (clickOutside)="close()">
<button (click)="toggle()">Menu</button>
@if (isOpen()) {
<div class="dropdown-menu">
<a href="#">Item 1</a>
<a href="#">Item 2</a>
</div>
}
</div>
`
})
export class DropdownComponent {
isOpen = signal(false);
toggle() {
this.isOpen.update(v => !v);
}
close() {
this.isOpen.set(false);
}
}
import { Directive, ElementRef, input, effect, inject } from '@angular/core';
@Directive({
selector: '[appAutoFocus]',
standalone: true
})
export class AutoFocusDirective {
private el = inject(ElementRef);
appAutoFocus = input<boolean>(true);
delay = input<number>(0);
constructor() {
effect(() => {
if (this.appAutoFocus()) {
setTimeout(() => {
this.el.nativeElement.focus();
}, this.delay());
}
});
}
}

Usage:

<input appAutoFocus [delay]="300" placeholder="Auto-focused after 300ms">
import { Directive, ElementRef, input, inject, Renderer2 } from '@angular/core';
@Directive({
selector: '[appTooltip]',
standalone: true,
host: {
'(mouseenter)': 'show()',
'(mouseleave)': 'hide()'
}
})
export class TooltipDirective {
private el = inject(ElementRef);
private renderer = inject(Renderer2);
appTooltip = input.required<string>();
position = input<'top' | 'bottom' | 'left' | 'right'>('top');
private tooltipElement?: HTMLElement;
show() {
this.tooltipElement = this.renderer.createElement('div');
this.renderer.addClass(this.tooltipElement, 'tooltip');
this.renderer.addClass(this.tooltipElement, `tooltip-${this.position()}`);
const text = this.renderer.createText(this.appTooltip());
this.renderer.appendChild(this.tooltipElement, text);
this.renderer.appendChild(document.body, this.tooltipElement);
this.positionTooltip();
}
hide() {
if (this.tooltipElement) {
this.renderer.removeChild(document.body, this.tooltipElement);
this.tooltipElement = undefined;
}
}
private positionTooltip() {
if (!this.tooltipElement) return;
const hostPos = this.el.nativeElement.getBoundingClientRect();
const tooltipPos = this.tooltipElement.getBoundingClientRect();
let top = 0;
let left = 0;
switch (this.position()) {
case 'top':
top = hostPos.top - tooltipPos.height - 8;
left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
break;
case 'bottom':
top = hostPos.bottom + 8;
left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
break;
case 'left':
top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
left = hostPos.left - tooltipPos.width - 8;
break;
case 'right':
top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
left = hostPos.right + 8;
break;
}
this.renderer.setStyle(this.tooltipElement, 'top', `${top}px`);
this.renderer.setStyle(this.tooltipElement, 'left', `${left}px`);
}
}

CSS:

.tooltip {
position: fixed;
background: #333;
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
z-index: 9999;
pointer-events: none;
}

Usage:

<button appTooltip="Click to save" [position]="'top'">
Save
</button>
import { Directive, ElementRef, input, effect, inject } from '@angular/core';
@Directive({
selector: '[appLazyLoad]',
standalone: true
})
export class LazyLoadDirective {
private el = inject(ElementRef);
appLazyLoad = input.required<string>();
placeholder = input<string>('data:image/svg+xml,...'); // Base64 placeholder
private observer?: IntersectionObserver;
constructor() {
effect(() => {
this.setupObserver();
});
}
private setupObserver() {
this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage();
this.observer?.disconnect();
}
});
});
this.observer.observe(this.el.nativeElement);
}
private loadImage() {
const img = this.el.nativeElement as HTMLImageElement;
img.src = this.appLazyLoad();
}
ngOnDestroy() {
this.observer?.disconnect();
}
}

Usage:

<img appLazyLoad="/assets/large-image.jpg"
[placeholder]="'data:image/svg+xml;base64,...'"
alt="Lazy loaded image">
import { Directive, TemplateRef, ViewContainerRef, input, effect, inject } from '@angular/core';
@Directive({
selector: '[appHasPermission]',
standalone: true
})
export class HasPermissionDirective {
private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);
private authService = inject(AuthService);
appHasPermission = input.required<string | string[]>();
constructor() {
effect(() => {
this.updateView();
});
}
private updateView() {
const permissions = Array.isArray(this.appHasPermission())
? this.appHasPermission() as string[]
: [this.appHasPermission() as string];
const hasPermission = permissions.some(p =>
this.authService.hasPermission(p)
);
if (hasPermission) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
}

Usage:

<button *appHasPermission="'admin'">Admin Only</button>
<div *appHasPermission="['editor', 'admin']">Editor or Admin</div>
import { Directive, output, inject } from '@angular/core';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
@Directive({
selector: '[appDebounceClick]',
standalone: true,
host: {
'(click)': 'onClick()'
}
})
export class DebounceClickDirective {
debounceClick = output<void>();
private clicks = new Subject<void>();
constructor() {
this.clicks
.pipe(debounceTime(300))
.subscribe(() => this.debounceClick.emit());
}
onClick() {
this.clicks.next();
}
ngOnDestroy() {
this.clicks.complete();
}
}

Usage:

<button appDebounceClick (debounceClick)="save()">
Save (debounced)
</button>
import { Directive, TemplateRef, ViewContainerRef, input, effect, inject } from '@angular/core';
@Directive({
selector: '[appUnless]',
standalone: true
})
export class UnlessDirective {
private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);
appUnless = input.required<boolean>();
constructor() {
effect(() => {
if (!this.appUnless()) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
});
}
}

Usage:

<div *appUnless="isHidden">
This shows when isHidden is false
</div>
import { Directive, TemplateRef, ViewContainerRef, input, effect, inject } from '@angular/core';
@Directive({
selector: '[appRepeat]',
standalone: true
})
export class RepeatDirective {
private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);
appRepeat = input.required<number>();
constructor() {
effect(() => {
this.viewContainer.clear();
const count = this.appRepeat();
for (let i = 0; i < count; i++) {
this.viewContainer.createEmbeddedView(this.templateRef, {
$implicit: i,
index: i
});
}
});
}
}

Usage:

<div *appRepeat="5; let i = index">
Item {{ i }}
</div>

Compose multiple directives together for powerful reusability.

// Base tooltip directive
@Directive({
selector: '[appTooltip]',
standalone: true
})
export class TooltipDirective {
tooltip = input<string>('');
// ... tooltip implementation
}
// Highlight directive
@Directive({
selector: '[appHighlight]',
standalone: true
})
export class HighlightDirective {
color = input<string>('yellow');
// ... highlight implementation
}
// Component using host directives
@Component({
selector: 'app-fancy-button',
standalone: true,
hostDirectives: [
{
directive: TooltipDirective,
inputs: ['tooltip']
},
{
directive: HighlightDirective,
inputs: ['color: highlightColor']
}
],
template: `<button><ng-content /></button>`
})
export class FancyButtonComponent {}

Usage:

<app-fancy-button
tooltip="Click me!"
highlightColor="lightblue">
Fancy Button
</app-fancy-button>
// ✅ Good - Modern signals
@Directive({
selector: '[appDirective]',
standalone: true
})
export class MyDirective {
value = input<string>('');
config = input<Config>({ /* defaults */ });
}
// ❌ Avoid - Old decorator style
@Directive({
selector: '[appDirective]',
standalone: true
})
export class MyDirective {
@Input() value = '';
@Input() config: Config = { /* defaults */ };
}
// ✅ Good - Host object
@Directive({
selector: '[appDirective]',
standalone: true,
host: {
'(click)': 'onClick($event)',
'(mouseenter)': 'onMouseEnter()',
'[class.active]': 'isActive()'
}
})
export class MyDirective {
isActive = signal(false);
onClick(event: MouseEvent) { }
onMouseEnter() { }
}
// ❌ Avoid - Decorators
@Directive({
selector: '[appDirective]',
standalone: true
})
export class MyDirective {
@HostBinding('class.active') isActive = false;
@HostListener('click', ['$event']) onClick(e: MouseEvent) { }
}
// ✅ Good - inject() function
@Directive({
selector: '[appDirective]',
standalone: true
})
export class MyDirective {
private el = inject(ElementRef);
private renderer = inject(Renderer2);
private service = inject(MyService);
}
// ❌ Avoid - Constructor injection
@Directive({
selector: '[appDirective]',
standalone: true
})
export class MyDirective {
constructor(
private el: ElementRef,
private renderer: Renderer2,
private service: MyService
) {}
}
@Directive({
selector: '[appDirective]',
standalone: true
})
export class MyDirective {
private subscription?: Subscription;
private observer?: IntersectionObserver;
ngOnDestroy() {
this.subscription?.unsubscribe();
this.observer?.disconnect();
}
}
// ✅ Good - Clear, prefixed selectors
@Directive({ selector: '[appHighlight]' })
@Directive({ selector: '[appClickOutside]' })
@Directive({ selector: '[appLazyLoad]' })
// ❌ Avoid - Generic or unprefixed
@Directive({ selector: '[highlight]' })
@Directive({ selector: '[outside]' })
@Directive({ selector: '[lazy]' })
import { Directive, input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
@Directive({
selector: '[appMinValue]',
standalone: true,
providers: [{
provide: NG_VALIDATORS,
useExisting: MinValueDirective,
multi: true
}]
})
export class MinValueDirective implements Validator {
appMinValue = input.required<number>();
validate(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (value === null || value === undefined || value === '') {
return null;
}
return value < this.appMinValue()
? { minValue: { min: this.appMinValue(), actual: value } }
: null;
}
}

Usage:

<input type="number" [appMinValue]="18" [(ngModel)]="age">
  • Understand the three types of directives
  • Create attribute directives with signals
  • Use host object for event binding
  • Implement structural directives
  • Compose directives with host directives
  • Handle cleanup in ngOnDestroy
  • Use inject() for dependency injection
  • Follow naming conventions with prefixes
  1. Dynamic Components - Load components dynamically
  2. Content Projection - Create flexible component APIs
  3. Advanced DI Patterns - Master dependency injection

Pro Tip: Directives are perfect for cross-cutting concerns like logging, analytics, accessibility features, and DOM manipulation. Keep them focused on a single responsibility for maximum reusability! 🎯