--- name: angular-directives description: Create custom directives in Angular v20+ for DOM manipulation and behavior extension. Use for attribute directives that modify element behavior/appearance, structural directives for portals/overlays, and host directives for composition. Triggers on creating reusable DOM behaviors, extending element functionality, or composing behaviors across components. Note - use native @if/@for/@switch for control flow, not custom structural directives. --- # Angular Directives Create custom directives for reusable DOM manipulation and behavior in Angular v20+. ## Attribute Directives Modify the appearance or behavior of an element: ```typescript 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`: ```typescript @Directive({ selector: '[appTooltip]', host: { '(mouseenter)': 'show()', '(mouseleave)': 'hide()', '[attr.aria-describedby]': 'tooltipId', }, }) export class TooltipDirective { text = input.required({ alias: 'appTooltip' }); position = input<'top' | 'bottom' | 'left' | 'right'>('top'); 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 ```typescript @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 ```typescript @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 ```typescript @Directive({ selector: '[appShortcut]', host: { '(document:keydown)': 'onKeydown($event)', }, }) export class ShortcutDirective { key = input.required({ alias: 'appShortcut' }); ctrl = input(false, { transform: booleanAttribute }); shift = input(false, { transform: booleanAttribute }); alt = input(false, { transform: booleanAttribute }); 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: ```typescript import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core'; @Directive({ selector: '[appPortal]', }) export class PortalDirective implements OnInit, OnDestroy { private templateRef = inject(TemplateRef); private viewContainerRef = inject(ViewContainerRef); private viewRef: EmbeddedViewRef | null = null; // Target container selector or element target = input('body', { alias: 'appPortal' }); 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): ```typescript @Directive({ selector: '[appLazyRender]', }) export class LazyRenderDirective { private templateRef = inject(TemplateRef); private viewContainer = inject(ViewContainerRef); private rendered = false; condition = input.required({ alias: 'appLazyRender' }); 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 ```typescript interface TemplateContext { $implicit: T; item: T; index: number; } @Directive({ selector: '[appTemplateOutlet]', }) export class TemplateOutletDirective { private viewContainer = inject(ViewContainerRef); private currentView: EmbeddedViewRef> | null = null; template = input.required>>({ alias: 'appTemplateOutlet' }); context = input.required({ alias: 'appTemplateOutletContext' }); index = input(0, { alias: 'appTemplateOutletIndex' }); 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 // //
{{ i }}: {{ item.name }}
//
// ``` ## Host Directives Compose directives on components or other directives: ```typescript // 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: ``, }) export class CustomButtonComponent { private disableable = inject(DisableableDirective); clicked = output(); onClick(event: Event) { if (!this.disableable.disabled()) { this.clicked.emit(); } } } // Usage: Click me ``` ### Exposing Host Directive Outputs ```typescript @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: ``, }) export class CardComponent {} // Usage: ... ``` ## Directive Composition API Combine multiple behaviors: ```typescript // 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: ``, }) export class MaterialButtonComponent {} ``` For advanced patterns, see [references/directive-patterns.md](references/directive-patterns.md).