Angular directives are one of the framework's most powerful features, yet many developers rely solely on built-in directives and never explore creating their own. Custom directives can dramatically improve code reusability, reduce boilerplate, and make your templates cleaner and more expressive.
In this article, I'll walk you through several practical directives built with Angular 21's modern patterns—using standalone components, signals, and the latest best practices. Each addresses a common pattern that typically requires repetitive code or complex component logic.
1. Auto-Focus Directive
The Problem: You constantly write ViewChild queries and lifecycle hooks just to focus an input field when a component loads or a modal opens.
The Solution: A signal-based directive that focuses an element automatically.
import { Directive, ElementRef, effect, input } from '@angular/core';
@Directive({
selector: '[appAutoFocus]',
standalone: true
})
export class AutoFocusDirective {
enabled = input(true, { alias: 'appAutoFocus' });
delay = input(0, { alias: 'autoFocusDelay' });
constructor(private el: ElementRef<HTMLElement>) {
effect(() => {
if (this.enabled()) {
setTimeout(() => {
this.el.nativeElement.focus();
}, this.delay());
}
});
}
}
Usage:
import { Component } from '@angular/core';
import { AutoFocusDirective } from './directives/auto-focus.directive';
@Component({
selector: 'app-login',
standalone: true,
imports: [AutoFocusDirective],
template: `
<input type="text" appAutoFocus placeholder="I'm focused on load">
<input type="text" [appAutoFocus]="shouldFocus()" placeholder="Conditional">
<input type="text" appAutoFocus [autoFocusDelay]="300" placeholder="Delayed">
`
})
export class LoginComponent {
shouldFocus = signal(true);
}
This directive uses Angular 21's signal inputs and the effect() API for reactive focusing logic.
2. Click Outside Directive
The Problem: Detecting clicks outside an element (for dropdowns, modals, or menus) requires adding document event listeners, managing cleanup, and writing the same logic repeatedly.
The Solution: A modern directive using outputFromObservable for clean event emission.
import { Directive, ElementRef, effect, inject } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { fromEvent, filter, map } from 'rxjs';
@Directive({
selector: '[appClickOutside]',
standalone: true
})
export class ClickOutsideDirective {
private elementRef = inject(ElementRef);
clickOutside = outputFromObservable(
fromEvent<MouseEvent>(document, 'click').pipe(
filter(event => {
const clickedInside = this.elementRef.nativeElement.contains(event.target);
return !clickedInside;
}),
map(() => undefined)
)
);
}
Usage:
import { Component, signal } from '@angular/core';
import { ClickOutsideDirective } from './directives/click-outside.directive';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-dropdown',
standalone: true,
imports: [ClickOutsideDirective, CommonModule],
template: `
<div class="dropdown" (appClickOutside)="closeDropdown()">
<button (click)="toggleDropdown()">Menu</button>
@if (isOpen()) {
<ul>
<li>Option 1</li>
<li>Option 2</li>
<li>Option 3</li>
</ul>
}
</div>
`,
styles: [`
.dropdown { position: relative; }
ul { position: absolute; background: white; border: 1px solid #ccc; }
`]
})
export class DropdownComponent {
isOpen = signal(false);
toggleDropdown() {
this.isOpen.update(v => !v);
}
closeDropdown() {
this.isOpen.set(false);
}
}
3. Debounce Input Directive
The Problem: You need to debounce input events for search boxes or filters, usually implementing this logic in multiple components.
The Solution: A directive that debounces input events and emits the debounced value.
import { Directive, inject, ElementRef, effect } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { fromEvent, debounceTime, map, distinctUntilChanged } from 'rxjs';
import { input } from '@angular/core';
@Directive({
selector: '[appDebounce]',
standalone: true
})
export class DebounceDirective {
debounceTime = input(300, { alias: 'appDebounce' });
private el = inject(ElementRef);
debounced = outputFromObservable(
fromEvent<Event>(this.el.nativeElement, 'input').pipe(
map(event => (event.target as HTMLInputElement).value),
debounceTime(this.debounceTime()),
distinctUntilChanged()
)
);
}
Usage:
import { Component, signal } from '@angular/core';
import { DebounceDirective } from './directives/debounce.directive';
@Component({
selector: 'app-search',
standalone: true,
imports: [DebounceDirective],
template: `
<input
type="text"
[appDebounce]="500"
(debounced)="onSearch($event)"
placeholder="Search...">
<p>Searching for: {{ searchTerm() }}</p>
`
})
export class SearchComponent {
searchTerm = signal('');
onSearch(term: string) {
this.searchTerm.set(term);
// Perform search logic here
console.log('Searching for:', term);
}
}
4. Copy to Clipboard Directive
The Problem: Implementing copy-to-clipboard functionality requires the Clipboard API boilerplate and success/error handling in every component.
The Solution: A directive that handles copying with visual feedback.
import { Directive, HostListener, inject, input } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { Subject } from 'rxjs';
@Directive({
selector: '[appCopyClipboard]',
standalone: true
})
export class CopyClipboardDirective {
copyText = input.required<string>({ alias: 'appCopyClipboard' });
private copiedSubject = new Subject<boolean>();
copied = outputFromObservable(this.copiedSubject.asObservable());
@HostListener('click')
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.copyText());
this.copiedSubject.next(true);
} catch (err) {
console.error('Failed to copy:', err);
this.copiedSubject.next(false);
}
}
}
Usage:
import { Component, signal } from '@angular/core';
import { CopyClipboardDirective } from './directives/copy-clipboard.directive';
@Component({
selector: 'app-code-snippet',
standalone: true,
imports: [CopyClipboardDirective],
template: `
<div class="code-block">
<pre><code>{{ code }}</code></pre>
<button
[appCopyClipboard]="code"
(copied)="onCopied($event)">
{{ copyStatus() }}
</button>
</div>
`,
styles: [`
.code-block {
position: relative;
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
}
button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
`]
})
export class CodeSnippetComponent {
code = 'npm install @angular/core';
copyStatus = signal('Copy');
onCopied(success: boolean) {
this.copyStatus.set(success ? 'Copied!' : 'Failed');
setTimeout(() => this.copyStatus.set('Copy'), 2000);
}
}
5. Lazy Load Image Directive
The Problem: You want images to load only when they're about to enter the viewport to improve performance.
The Solution: A directive using the Intersection Observer API with Angular 21's modern patterns.
import { Directive, ElementRef, inject, input, effect } from '@angular/core';
@Directive({
selector: 'img[appLazyLoad]',
standalone: true
})
export class LazyLoadDirective {
src = input.required<string>({ alias: 'appLazyLoad' });
placeholder = input<string>('data:image/svg+xml,...');
private el = inject(ElementRef);
private observer?: IntersectionObserver;
constructor() {
effect(() => {
const imgElement = this.el.nativeElement as HTMLImageElement;
imgElement.src = this.placeholder();
this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
imgElement.src = this.src();
this.observer?.disconnect();
}
});
});
this.observer.observe(imgElement);
});
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
Usage:
import { Component } from '@angular/core';
import { LazyLoadDirective } from './directives/lazy-load.directive';
@Component({
selector: 'app-gallery',
standalone: true,
imports: [LazyLoadDirective],
template: `
<div class="gallery">
@for (image of images; track image.id) {
<img
[appLazyLoad]="image.url"
[alt]="image.alt"
class="gallery-image">
}
</div>
`,
styles: [`
.gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.gallery-image { width: 100%; height: 200px; object-fit: cover; }
`]
})
export class GalleryComponent {
images = [
{ id: 1, url: 'https://example.com/image1.jpg', alt: 'Image 1' },
{ id: 2, url: 'https://example.com/image2.jpg', alt: 'Image 2' },
{ id: 3, url: 'https://example.com/image3.jpg', alt: 'Image 3' },
];
}
6. Tooltip Directive
The Problem: Adding tooltips requires third-party libraries or complex component logic with positioning calculations.
The Solution: A lightweight directive that creates tooltips dynamically.
import { Directive, ElementRef, HostListener, inject, input, Renderer2 } from '@angular/core';
@Directive({
selector: '[appTooltip]',
standalone: true
})
export class TooltipDirective {
tooltipText = input.required<string>({ alias: 'appTooltip' });
position = input<'top' | 'bottom' | 'left' | 'right'>('top');
private el = inject(ElementRef);
private renderer = inject(Renderer2);
private tooltip?: HTMLElement;
@HostListener('mouseenter')
onMouseEnter() {
this.createTooltip();
}
@HostListener('mouseleave')
onMouseLeave() {
this.removeTooltip();
}
private createTooltip() {
this.tooltip = this.renderer.createElement('span');
this.renderer.appendChild(
this.tooltip,
this.renderer.createText(this.tooltipText())
);
this.renderer.appendChild(document.body, this.tooltip);
this.renderer.addClass(this.tooltip, 'app-tooltip');
this.renderer.addClass(this.tooltip, `tooltip-${this.position()}`);
const hostPos = this.el.nativeElement.getBoundingClientRect();
const tooltipPos = this.tooltip.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.tooltip, 'top', `${top}px`);
this.renderer.setStyle(this.tooltip, 'left', `${left}px`);
}
private removeTooltip() {
if (this.tooltip) {
this.renderer.removeChild(document.body, this.tooltip);
this.tooltip = undefined;
}
}
ngOnDestroy() {
this.removeTooltip();
}
}
Usage:
import { Component } from '@angular/core';
import { TooltipDirective } from './directives/tooltip.directive';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [TooltipDirective],
template: `
<button appTooltip="Click to save changes">Save</button>
<button [appTooltip]="'Delete permanently'" [position]="'bottom'">Delete</button>
<span [appTooltip]="'This feature is coming soon'" [position]="'right'">
🚀 New Feature
</span>
`,
styles: [`
:host ::ng-deep .app-tooltip {
position: fixed;
background: #333;
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
z-index: 1000;
pointer-events: none;
white-space: nowrap;
}
`]
})
export class DashboardComponent {}
7. Permission Directive
The Problem: You repeatedly check user permissions in templates using *ngIf, leading to duplication and tight coupling with your auth service.
The Solution: A structural directive that handles permission checks declaratively.
import { Directive, inject, input, TemplateRef, ViewContainerRef, effect } from '@angular/core';
import { AuthService } from '../services/auth.service';
@Directive({
selector: '[appHasPermission]',
standalone: true
})
export class HasPermissionDirective {
permission = input.required<string | string[]>({ alias: 'appHasPermission' });
requireAll = input(false, { alias: 'appHasPermissionRequireAll' });
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private authService = inject(AuthService);
constructor() {
effect(() => {
const permissions = Array.isArray(this.permission())
? this.permission() as string[]
: [this.permission() as string];
const hasPermission = this.requireAll()
? permissions.every(p => this.authService.hasPermission(p))
: permissions.some(p => this.authService.hasPermission(p));
this.viewContainer.clear();
if (hasPermission) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
});
}
}
Usage:
import { Component, signal } from '@angular/core';
import { HasPermissionDirective } from './directives/has-permission.directive';
@Component({
selector: 'app-admin-panel',
standalone: true,
imports: [HasPermissionDirective],
template: `
<div class="admin-panel">
<h2>Admin Panel</h2>
@if (appHasPermission: 'view_users') {
<button>View Users</button>
}
@if (appHasPermission: ['edit_users', 'delete_users']; requireAll: true) {
<button>Manage Users</button>
}
@if (appHasPermission: ['create_content', 'edit_content']) {
<button>Content Management</button>
}
</div>
`
})
export class AdminPanelComponent {}
Why Build These Directives?
Code Reusability: Write once, use everywhere. These directives eliminate repetitive code across your application.
Declarative Templates: Your templates become more readable and expressive, making intent immediately clear.
Separation of Concerns: Business logic stays in services and components, while presentation behavior lives in directives.
Modern Angular Patterns: Using signals,
inject(), signal inputs, andoutputFromObservablekeeps your code aligned with Angular 21's reactive architecture.Performance: Directives like lazy loading and debouncing directly improve app performance without cluttering component code.
Conclusion
Custom directives are an essential tool in any Angular developer's arsenal. The examples above demonstrate how directives can encapsulate common patterns, making your codebase more maintainable and your templates more expressive.
Start building these directives in your next project, and you'll quickly find yourself creating more custom directives to solve your specific challenges. The investment in creating reusable directives pays dividends in cleaner code and faster development cycles.
Remember: if you're writing the same template logic in multiple places, it's probably time to create a directive.
Top comments (0)