Angular Directives
Create custom directives for reusable DOM manipulation and behavior in Angular v20+.
Attribute Directives
Modify the appearance or behavior of an element:
import { Directive, input, effect, inject, ElementRef } from '@angular/core';
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {
private el = inject(ElementRef
// Input with alias matching selector color = input('yellow', { alias: 'appHighlight' });
constructor() { effect(() => { this.el.nativeElement.style.backgroundColor = this.color(); }); } }
// Usage:
Highlighted text
// Usage:Default yellow highlight
Using host Property
Prefer host over @HostBinding/@HostListener:
@Directive({
selector: '[appTooltip]',
host: {
'(mouseenter)': 'show()',
'(mouseleave)': 'hide()',
'[attr.aria-describedby]': 'tooltipId',
},
})
export class TooltipDirective {
text = input.required
tooltipId = tooltip-${crypto.randomUUID()};
private tooltipEl: HTMLElement | null = null;
private el = inject(ElementRef
show() {
this.tooltipEl = document.createElement('div');
this.tooltipEl.id = this.tooltipId;
this.tooltipEl.className = tooltip tooltip-${this.position()};
this.tooltipEl.textContent = this.text();
this.tooltipEl.setAttribute('role', 'tooltip');
document.body.appendChild(this.tooltipEl);
this.positionTooltip();
}
hide() { this.tooltipEl?.remove(); this.tooltipEl = null; }
private positionTooltip() { // Position logic based on this.position() and this.el } }
// Usage:
Class and Style Manipulation @Directive({ selector: '[appButton]', host: { 'class': 'btn', '[class.btn-primary]': 'variant() === "primary"', '[class.btn-secondary]': 'variant() === "secondary"', '[class.btn-sm]': 'size() === "small"', '[class.btn-lg]': 'size() === "large"', '[class.disabled]': 'disabled()', '[attr.disabled]': 'disabled() || null', }, }) export class ButtonDirective { variant = input<'primary' | 'secondary'>('primary'); size = input<'small' | 'medium' | 'large'>('medium'); disabled = input(false, { transform: booleanAttribute }); }
// Usage:
Event Handling
@Directive({
selector: '[appClickOutside]',
host: {
'(document:click)': 'onDocumentClick($event)',
},
})
export class ClickOutsideDirective {
private el = inject(ElementRef
clickOutside = output
onDocumentClick(event: MouseEvent) { if (!this.el.nativeElement.contains(event.target as Node)) { this.clickOutside.emit(); } } }
// Usage:
Keyboard Shortcuts
@Directive({
selector: '[appShortcut]',
host: {
'(document:keydown)': 'onKeydown($event)',
},
})
export class ShortcutDirective {
key = input.required
triggered = output
onKeydown(event: KeyboardEvent) { const keyMatch = event.key.toLowerCase() === this.key().toLowerCase(); const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey; const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey; const altMatch = this.alt() ? event.altKey : !event.altKey;
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
event.preventDefault();
this.triggered.emit(event);
}
} }
// Usage:
Structural Directives
Use structural directives for DOM manipulation beyond control flow (portals, overlays, dynamic insertion points). For conditionals and loops, use native @if, @for, @switch.
Portal Directive
Render content in a different DOM location:
import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';
@Directive({
selector: '[appPortal]',
})
export class PortalDirective implements OnInit, OnDestroy {
private templateRef = inject(TemplateRef
// Target container selector or element
target = input
ngOnInit() { const container = this.getContainer(); if (container) { this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef); this.viewRef.rootNodes.forEach(node => container.appendChild(node)); } }
ngOnDestroy() { this.viewRef?.destroy(); }
private getContainer(): HTMLElement | null { const target = this.target(); if (typeof target === 'string') { return document.querySelector(target); } return target; } }
// Usage: Render modal at body level //
Lazy Render Directive
Defer rendering until condition is met (one-time):
@Directive({
selector: '[appLazyRender]',
})
export class LazyRenderDirective {
private templateRef = inject(TemplateRef
condition = input.required
constructor() { effect(() => { // Only render once when condition becomes true if (this.condition() && !this.rendered) { this.viewContainer.createEmbeddedView(this.templateRef); this.rendered = true; } }); } }
// Usage: Render heavy component only when tab is first activated //
Template Outlet with Context
interface TemplateContext
@Directive({
selector: '[appTemplateOutlet]',
})
export class TemplateOutletDirective
template = input.required
constructor() { effect(() => { const template = this.template(); const context = this.context(); const index = this.index();
if (this.currentView) {
this.currentView.context.$implicit = context;
this.currentView.context.item = context;
this.currentView.context.index = index;
this.currentView.markForCheck();
} else {
this.currentView = this.viewContainer.createEmbeddedView(template, {
$implicit: context,
item: context,
index,
});
}
});
} }
// Usage: Custom list with template
//
Host Directives
Compose directives on components or other directives:
// Reusable behavior directives @Directive({ selector: '[focusable]', host: { 'tabindex': '0', '(focus)': 'onFocus()', '(blur)': 'onBlur()', '[class.focused]': 'isFocused()', }, }) export class FocusableDirective { isFocused = signal(false);
onFocus() { this.isFocused.set(true); } onBlur() { this.isFocused.set(false); } }
@Directive({ selector: '[disableable]', host: { '[class.disabled]': 'disabled()', '[attr.aria-disabled]': 'disabled()', }, }) export class DisableableDirective { disabled = input(false, { transform: booleanAttribute }); }
// Component using host directives
@Component({
selector: 'app-custom-button',
hostDirectives: [
FocusableDirective,
{
directive: DisableableDirective,
inputs: ['disabled'],
},
],
host: {
'role': 'button',
'(click)': 'onClick($event)',
'(keydown.enter)': 'onClick($event)',
'(keydown.space)': 'onClick($event)',
},
template: <ng-content />,
})
export class CustomButtonComponent {
private disableable = inject(DisableableDirective);
clicked = output
onClick(event: Event) { if (!this.disableable.disabled()) { this.clicked.emit(); } } }
// Usage:
Exposing Host Directive Outputs @Directive({ selector: '[hoverable]', host: { '(mouseenter)': 'onEnter()', '(mouseleave)': 'onLeave()', '[class.hovered]': 'isHovered()', }, }) export class HoverableDirective { isHovered = signal(false);
hoverChange = output
onEnter() { this.isHovered.set(true); this.hoverChange.emit(true); }
onLeave() { this.isHovered.set(false); this.hoverChange.emit(false); } }
@Component({
selector: 'app-card',
hostDirectives: [
{
directive: HoverableDirective,
outputs: ['hoverChange'],
},
],
template: <ng-content />,
})
export class CardComponent {}
// Usage:
Directive Composition API
Combine multiple behaviors:
// Base directives @Directive({ selector: '[withRipple]' }) export class RippleDirective { // Ripple effect implementation }
@Directive({ selector: '[withElevation]' }) export class ElevationDirective { elevation = input(2); }
// Composed component
@Component({
selector: 'app-material-button',
hostDirectives: [
RippleDirective,
{
directive: ElevationDirective,
inputs: ['elevation'],
},
{
directive: DisableableDirective,
inputs: ['disabled'],
},
],
template: <ng-content />,
})
export class MaterialButtonComponent {}
For advanced patterns, see references/directive-patterns.md.