Custom Directives in Angular
Custom directives are one of Angular's most powerful extension points. They let you package reusable DOM behaviour, add accessibility attributes, enforce interaction patterns, and create micro-frameworks — all without writing a component.
This page covers the full lifecycle of writing a custom directive: from a simple attribute directive through a full custom structural directive, including testing patterns.
Generating a Directive
# Angular CLI creates the directive + spec file ng generate directive shared/highlight # or short form ng g d shared/highlight
// Generated: src/app/shared/highlight.directive.ts
import { Directive } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true,
})
export class HighlightDirective {}
Example 1: Simple Hover Highlight
The classic starting example — change the background colour when the user hovers over an element.
import {
Directive,
ElementRef,
HostListener,
Input,
OnInit,
} from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true,
})
export class HighlightDirective implements OnInit {
@Input('appHighlight') hoverColor = '#ffeb3b';
@Input() defaultBg = '';
constructor(private el: ElementRef<HTMLElement>) {}
ngOnInit() {
this.el.nativeElement.style.transition = 'background-color 0.2s';
this.el.nativeElement.style.backgroundColor = this.defaultBg;
}
@HostListener('mouseenter')
onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.hoverColor;
}
@HostListener('mouseleave')
onMouseLeave() {
this.el.nativeElement.style.backgroundColor = this.defaultBg;
}
}
<p appHighlight>Highlights yellow on hover (default)</p> <p appHighlight="lightblue">Highlights blue on hover</p> <p [appHighlight]="theme.accentColor" defaultBg="#f5f5f5">Dynamic color</p>
Example 2: Click-to-Copy Directive
A practical directive that copies an element's text (or a specified value) to the clipboard when clicked.
import {
Directive,
HostListener,
Input,
Output,
EventEmitter,
ElementRef,
} from '@angular/core';
@Directive({
selector: '[appCopyToClipboard]',
standalone: true,
})
export class CopyToClipboardDirective {
// If provided, copies this value; otherwise copies the element's text
@Input('appCopyToClipboard') copyText = '';
@Output() copied = new EventEmitter<string>();
@Output() copyFailed = new EventEmitter<Error>();
constructor(private el: ElementRef<HTMLElement>) {}
@HostListener('click')
async onClick() {
const text = this.copyText || this.el.nativeElement.innerText;
try {
await navigator.clipboard.writeText(text);
this.copied.emit(text);
} catch (err) {
this.copyFailed.emit(err as Error);
}
}
}
<!-- Copies the element's own text -->
<code appCopyToClipboard (copied)="showToast('Copied!')">npm install @angular/core</code>
<!-- Copies a specific value -->
<button [appCopyToClipboard]="apiKey" (copied)="onCopied()">Copy API Key</button>
Example 3: Lazy Image Loading
A directive that uses the IntersectionObserver API to only load an image when it enters the viewport.
import {
Directive,
ElementRef,
Input,
OnInit,
OnDestroy,
} from '@angular/core';
@Directive({
selector: 'img[appLazyLoad]',
standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
@Input('appLazyLoad') lazySrc = '';
@Input() placeholder = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E';
private observer!: IntersectionObserver;
constructor(private el: ElementRef<HTMLImageElement>) {}
ngOnInit() {
const img = this.el.nativeElement;
img.src = this.placeholder;
this.observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
img.src = this.lazySrc;
img.onload = () => img.classList.add('loaded');
this.observer.unobserve(img);
}
},
{ threshold: 0.1 },
);
this.observer.observe(img);
}
ngOnDestroy() {
this.observer.disconnect();
}
}
<!-- Only loads the real image when it scrolls into view --> <img [appLazyLoad]="product.imageUrl" [alt]="product.name" />
Example 4: Debounced Input Directive
Debounce a text input so a search or API call is only triggered after the user stops typing.
import {
Directive,
EventEmitter,
HostListener,
Input,
OnDestroy,
Output,
} from '@angular/core';
@Directive({
selector: '[appDebounce]',
standalone: true,
})
export class DebounceDirective implements OnDestroy {
@Input() debounceTime = 300;
@Output() debounced = new EventEmitter<string>();
private timer: ReturnType<typeof setTimeout> | null = null;
@HostListener('input', ['$event'])
onInput(event: Event) {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.debounced.emit((event.target as HTMLInputElement).value);
}, this.debounceTime);
}
ngOnDestroy() {
if (this.timer) clearTimeout(this.timer);
}
}
@Component({
selector: 'app-search',
standalone: true,
imports: [DebounceDirective],
template: `
<input
[debounceTime]="500"
(debounced)="onSearch($event)"
placeholder="Search..."
/>
<p>Last search: {{ lastQuery }}</p>
`,
})
export class SearchComponent {
lastQuery = '';
onSearch(query: string) {
this.lastQuery = query;
// Call your API here
console.log('Searching:', query);
}
}
Example 5: Custom Structural Directive
A custom structural directive that renders its template only if the user has a required permission — a common access-control pattern.
// has-permission.directive.ts
import {
Directive,
Input,
OnInit,
TemplateRef,
ViewContainerRef,
inject,
} from '@angular/core';
import { AuthService } from './auth.service';
@Directive({
selector: '[appHasPermission]',
standalone: true,
})
export class HasPermissionDirective implements OnInit {
@Input('appHasPermission') permission = '';
private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);
private authService = inject(AuthService);
ngOnInit() {
if (this.authService.hasPermission(this.permission)) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
}
<!-- Only renders if the current user has 'delete:posts' permission --> <button *appHasPermission="'delete:posts'" (click)="deletePost()">Delete</button> <!-- Admin-only section --> <div *appHasPermission="'manage:users'"> <app-user-management-panel /> </div>
Directive Composition API (Angular 15+)
The Directive Composition API lets you apply multiple directives to a component's host element from within the @Component decorator, reducing the need for a wrapper element.
import { Component, Directive, HostBinding, HostListener } from '@angular/core';
// Reusable micro-directives
@Directive({ standalone: true })
export class DisabledStateDirective {
@HostBinding('attr.aria-disabled') @Input() disabled = false;
@HostBinding('class.disabled') get cls() { return this.disabled; }
}
@Directive({ standalone: true })
export class FocusRingDirective {
@HostBinding('class.focus-visible') hasFocus = false;
@HostListener('focusin') onFocusIn() { this.hasFocus = true; }
@HostListener('focusout') onFocusOut() { this.hasFocus = false; }
}
// Compose them onto a component's host
@Component({
selector: 'app-button',
standalone: true,
hostDirectives: [
{ directive: DisabledStateDirective, inputs: ['disabled'] },
FocusRingDirective,
],
template: `<ng-content />`,
})
export class ButtonComponent {}
Testing a Custom Directive
// highlight.directive.spec.ts
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';
@Component({
standalone: true,
imports: [HighlightDirective],
template: `<p appHighlight="cyan">Hover me</p>`,
})
class TestHostComponent {}
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
});
it('sets background on mouseenter', () => {
const p = fixture.debugElement.query(By.css('p')).nativeElement;
p.dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(p.style.backgroundColor).toBe('cyan');
});
it('clears background on mouseleave', () => {
const p = fixture.debugElement.query(By.css('p')).nativeElement;
p.dispatchEvent(new Event('mouseenter'));
p.dispatchEvent(new Event('mouseleave'));
fixture.detectChanges();
expect(p.style.backgroundColor).toBe('');
});
});
Directive Design Checklist
Give the directive a clear, action-describing name (appCopyToClipboard, appLazyLoad).
Use a consistent app prefix to avoid name collisions with third-party directives.
Expose @Input() properties for configuration so the directive can be reused.
Emit @Output() events to communicate results back to the parent component.
Always clean up in ngOnDestroy: clear timers, unsubscribe, disconnect observers.
Use Renderer2 for DOM manipulation to keep the directive SSR-safe.
Mark directives as standalone: true for modern Angular projects.
Write tests via a TestHostComponent — test behaviour, not implementation details.
Summary
Directive Type | When to Use |
|---|---|
Attribute directive | Add behaviour/styling to an existing element (hover, copy, lazy load) |
Structural directive | Conditionally render or repeat a template (permission guard, repeat N times) |
Host directive (composition) | Bundle behaviour onto a component's host element without a wrapper |