Directives (Attribute, Structural, Custom)
Directives are a powerful Angular-specific feature that attach reusable behaviors to DOM elements in component templates. This concept doesn't exist in React and significantly improves code reusability in large-scale development.
1. Three Types of Directives
Angular directives fall into three categories. All directives are defined with the @Directive decorator (or its superset, @Component).
| Type | Decorator | Role | Examples |
|---|---|---|---|
| Component directive | @Component | A directive with a template (the UI building block) | <app-button> |
| Structural directive | @Directive | Adds, removes, or reshapes DOM structure | *ngIf, *ngFor, @if, @for |
| Attribute directive | @Directive | Changes appearance or behavior without adding/removing DOM nodes | ngClass, ngStyle, [appHighlight] |
Component Directives
@Component internally extends @Directive. It adds a template (template/templateUrl) and styles (styles/styleUrls) to form the UI building block.
@Component({
selector: 'app-button',
standalone: true,
template: `<button class="btn"><ng-content /></button>`,
})
export class ButtonComponent {}
Structural Directives
The asterisk (*) prefix marks a structural directive. Internally it is desugared into <ng-template>, which controls DOM insertion and removal. Angular 17+ offers the @if / @for control-flow syntax as a modern alternative.
<!-- Legacy structural directives -->
<div *ngIf="isLoggedIn">Welcome!</div>
<li *ngFor="let item of items; let i = index">{{ i }}: {{ item.name }}</li>
<!-- Angular 17+ control-flow syntax (recommended) -->
@if (isLoggedIn) {
<div>Welcome!</div>
}
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
}
Attribute Directives
Attribute directives add styles, classes, or event handling to existing elements without changing the DOM structure.
<p [ngClass]="{ 'active': isActive, 'error': hasError }">Text</p>
<p [appHighlight]="'#ffeb3b'">Highlights on hover</p>
React has no "directive" concept. The closest alternatives are:
- Structural directives → inline JSX conditions (
{condition && <Component />}) and list renders (items.map(...)) - Attribute directives → custom hooks (
useHover,useClickOutside) or HOCs (Higher-Order Components)
The key difference is that Angular directives are applied declaratively in the template.
2. Built-in Attribute Directives
Angular ships several useful attribute directives out of the box. Import CommonModule (or the individual standalone directive) to use them.
ngClass — Dynamic CSS classes
ngClass accepts an object, array, or string.
<!-- Object syntax (recommended): key = class name, value = boolean -->
<div [ngClass]="{ 'active': isActive, 'disabled': isDisabled, 'error': hasError }">
Object syntax
</div>
<!-- Array syntax: apply multiple classes at once -->
<div [ngClass]="['base-style', isActive ? 'active' : '', sizeClass]">
Array syntax
</div>
<!-- String syntax: simple toggle -->
<div [ngClass]="isActive ? 'active' : 'inactive'">
String syntax
</div>
<!-- For a single class, the [class.name] shorthand is cleaner -->
<div [class.active]="isActive" [class.error]="hasError">
Shorthand (best for simple cases)
</div>
In React you typically use clsx or classnames:
// React + clsx
import clsx from 'clsx';
<div className={clsx({ active: isActive, disabled: isDisabled, error: hasError })}>
Text
</div>
Angular's ngClass provides the same functionality without a third-party library.
ngStyle — Dynamic inline styles
<!-- Object syntax for inline styles -->
<div [ngStyle]="{ 'color': textColor, 'font-size.px': fontSize, 'opacity': isVisible ? 1 : 0 }">
Dynamic styles
</div>
<!-- [style.property] shorthand for single properties -->
<div [style.color]="textColor" [style.font-size.px]="fontSize">
Shorthand
</div>
ngModel — Two-way binding
Provides two-way binding for form elements. Requires FormsModule.
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-simple-form',
standalone: true,
imports: [FormsModule],
template: `
<input [(ngModel)]="username" placeholder="Enter username" />
<p>Value: {{ username }}</p>
`,
})
export class SimpleFormComponent {
username = '';
}
[(ngModel)] is the "banana in a box" syntax. Internally it combines [ngModel]="value" (property binding) and (ngModelChange)="value = $event" (event binding).
This is equivalent to React's value={value} onChange={(e) => setValue(e.target.value)}.
3. Creating Custom Attribute Directives
Custom attribute directives let you attach a shared behavior to any DOM element across multiple components. The example below creates a HighlightDirective that changes the background color on hover.
Generate command
ng generate directive highlight
# or shorthand
ng g d highlight
Implementation
import {
Directive,
ElementRef,
HostBinding,
HostListener,
Input,
Renderer2,
} from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true,
})
export class HighlightDirective {
// Use the directive selector itself as an input: [appHighlight]="'yellow'"
@Input() appHighlight = 'yellow';
// @HostBinding directly binds a property on the host element
@HostBinding('style.transition') transition = 'background-color 0.3s ease';
constructor(private el: ElementRef, private renderer: Renderer2) {}
@HostListener('mouseenter')
onMouseEnter(): void {
// Renderer2 keeps SSR (server-side rendering) safe
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', this.appHighlight);
}
@HostListener('mouseleave')
onMouseLeave(): void {
this.renderer.removeStyle(this.el.nativeElement, 'backgroundColor');
}
}
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
@Component({
selector: 'app-root',
standalone: true,
imports: [HighlightDirective],
template: `
<!-- Default color (yellow) -->
<p appHighlight>Hover over me</p>
<!-- Custom color -->
<p [appHighlight]="'#a5d6a7'">Green highlight</p>
<button [appHighlight]="'#ef9a9a'">Red highlight</button>
`,
})
export class AppComponent {}
Why use Renderer2 instead of direct DOM access?
| Approach | Pros | Cons |
|---|---|---|
el.nativeElement.style.color = '...' | Simple | Not SSR-safe, hard to test |
Renderer2.setStyle(...) | SSR-safe, testable | Slightly more verbose |
React custom hooks reuse component state and lifecycle logic. Angular directives attach behavior to DOM elements.
// React: useHover hook manages hover state
function useHover() {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
el.addEventListener('mouseenter', () => setIsHovered(true));
el.addEventListener('mouseleave', () => setIsHovered(false));
return () => { /* cleanup */ };
}, []);
return { ref, isHovered };
}
// The component must apply the style itself
function MyComponent() {
const { ref, isHovered } = useHover();
return <p ref={ref} style={{ background: isHovered ? 'yellow' : '' }}>Text</p>;
}
With Angular's directive, just write appHighlight in the template — the logic and styling are encapsulated together.
4. Creating Custom Structural Directives
Structural directives use TemplateRef and ViewContainerRef to control whether elements are added to or removed from the DOM.
| Class | Role |
|---|---|
TemplateRef | Holds a reference to the <ng-template> where *appXxx is applied |
ViewContainerRef | Container that renders the template into the DOM |
Example: role-based permission directive
import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';
@Directive({
selector: '[appHasRole]',
standalone: true,
})
export class HasRoleDirective implements OnInit {
private requiredRole = '';
@Input() set appHasRole(role: string) {
this.requiredRole = role;
this.updateView();
}
constructor(
private templateRef: TemplateRef<any>,
private vcr: ViewContainerRef,
private authService: AuthService,
) {}
ngOnInit(): void {
this.updateView();
}
private updateView(): void {
this.vcr.clear(); // Clear before re-evaluating
if (this.authService.hasRole(this.requiredRole)) {
// Render the template into the DOM
this.vcr.createEmbeddedView(this.templateRef);
}
}
}
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthService {
private currentUserRoles = ['user', 'editor'];
hasRole(role: string): boolean {
return this.currentUserRoles.includes(role);
}
}
<!-- Only shown to users with 'admin' role -->
<div *appHasRole="'admin'">
<h3>Admin Menu</h3>
<ul>
<li>User Management</li>
<li>System Settings</li>
</ul>
</div>
<!-- Shown to 'editor' role -->
<button *appHasRole="'editor'">Edit Article</button>
<!-- Visible to everyone -->
<p>Public content</p>
React handles conditional rendering with inline JSX expressions:
// React: inline conditional rendering
function AdminMenu() {
return (
<>
{hasRole('admin') && (
<div>
<h3>Admin Menu</h3>
</div>
)}
{hasRole('editor') && <button>Edit Article</button>}
</>
);
}
// Or wrap in a custom component
function RequireRole({ role, children }) {
return hasRole(role) ? children : null;
}
Angular's *appHasRole keeps permission logic completely out of components and applies it declaratively in templates — a significant maintainability advantage for large teams.
5. HostDirective (Directive Composition)
Since Angular 15, the hostDirectives property enables directive composition: a component or directive can incorporate the functionality of other directives.
Basic usage
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
import { TooltipDirective } from './tooltip.directive';
@Component({
selector: 'app-button',
standalone: true,
// Compose directives via hostDirectives
hostDirectives: [
{
directive: HighlightDirective,
// Expose inputs/outputs to the parent (aliases supported)
inputs: ['appHighlight: color'],
},
{
directive: TooltipDirective,
inputs: ['appTooltip: tooltip'],
outputs: ['tooltipShown'],
},
],
template: `<button class="btn"><ng-content /></button>`,
})
export class ButtonComponent {}
<!-- Pass values to composed directives through aliased inputs -->
<app-button color="#b3e5fc" tooltip="Click to submit">Submit</app-button>
Composition vs inheritance
| Aspect | Composition (hostDirectives) | Inheritance |
|---|---|---|
| Combining multiple features | Easy — list multiple directives | Hard — single inheritance |
| Loose coupling | High — dependencies are explicit | Low — parent changes ripple down |
| Testability | High — each directive tested independently | Low — whole hierarchy needed for setup |
| Angular recommendation | ✅ Recommended | ❌ Not recommended |
React achieves similar composition through HOC chains or combined hooks:
// React: HOC composition
const EnhancedButton = withTooltip(withHighlight(Button));
// Or combining hooks
function ButtonComponent({ color, tooltip }) {
const highlightProps = useHighlight(color);
const tooltipProps = useTooltip(tooltip);
return <button {...highlightProps} {...tooltipProps}>Submit</button>;
}
hostDirectives is a framework-level composition API that eliminates much of the boilerplate required in React.
6. Directives vs React Hooks
Both mechanisms exist for logic reuse, but they take very different approaches.
| Aspect | Angular Directives | React Hooks |
|---|---|---|
| How applied | Written as an attribute in the HTML template | Called inside a component function |
| Target | Acts directly on a DOM element | Acts on component state, lifecycle, and side effects |
| DOM manipulation | Direct and safe via ElementRef / Renderer2 | Indirect via useRef + useEffect |
| Reuse granularity | Declaratively applied to any DOM element | Logic is mixed into component functions |
| Template impact | Structural directives control DOM presence | JSX conditionals and list expressions |
| Dependency injection | Services injected via Angular DI | Context API or external stores |
| Testing | Test with TestBed and a host element | Test with renderHook / RTL independently |
Summary
- Angular directives: DOM-centric and declarative. Simply add an attribute to the template to attach behavior. Easy to standardize across large teams.
- React hooks: Component-logic-centric. Excel at encapsulating state, side effects, and context. Flexible to compose as plain JavaScript functions.
7. Practical Patterns
a. Tooltip directive
A lightweight hover tooltip using @HostListener and dynamic element creation.
import {
Directive,
ElementRef,
HostListener,
Input,
OnDestroy,
Renderer2,
} from '@angular/core';
@Directive({
selector: '[appTooltip]',
standalone: true,
})
export class TooltipDirective implements OnDestroy {
@Input() appTooltip = '';
private tooltipEl: HTMLElement | null = null;
constructor(private el: ElementRef, private renderer: Renderer2) {}
@HostListener('mouseenter')
onMouseEnter(): void {
this.tooltipEl = this.renderer.createElement('span');
this.renderer.appendChild(
this.tooltipEl,
this.renderer.createText(this.appTooltip),
);
this.renderer.setStyle(this.tooltipEl, 'position', 'absolute');
this.renderer.setStyle(this.tooltipEl, 'background', '#333');
this.renderer.setStyle(this.tooltipEl, 'color', '#fff');
this.renderer.setStyle(this.tooltipEl, 'padding', '4px 8px');
this.renderer.setStyle(this.tooltipEl, 'border-radius', '4px');
this.renderer.setStyle(this.tooltipEl, 'font-size', '12px');
this.renderer.setStyle(this.tooltipEl, 'white-space', 'nowrap');
this.renderer.setStyle(this.tooltipEl, 'z-index', '1000');
this.renderer.setStyle(this.el.nativeElement, 'position', 'relative');
this.renderer.appendChild(this.el.nativeElement, this.tooltipEl);
}
@HostListener('mouseleave')
onMouseLeave(): void {
this.removeTooltip();
}
ngOnDestroy(): void {
this.removeTooltip();
}
private removeTooltip(): void {
if (this.tooltipEl) {
this.renderer.removeChild(this.el.nativeElement, this.tooltipEl);
this.tooltipEl = null;
}
}
}
<button [appTooltip]="'Click to save'">Save</button>
<span [appTooltip]="'This field is required'">Username *</span>
b. Click-outside detection directive
Detects clicks outside the host element — useful for closing dropdowns and modals.
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
selector: '[appClickOutside]',
standalone: true,
})
export class ClickOutsideDirective {
@Output() clickOutside = new EventEmitter<void>();
constructor(private elementRef: ElementRef) {}
// Listen to the entire document's click event
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const target = event.target as Node;
if (!this.elementRef.nativeElement.contains(target)) {
this.clickOutside.emit();
}
}
}
<div class="dropdown" (appClickOutside)="closeDropdown()">
<button (click)="toggleDropdown()">Menu ▼</button>
@if (isOpen) {
<ul class="dropdown-menu">
<li>Item 1</li>
<li>Item 2</li>
</ul>
}
</div>
c. Auto-focus directive
Automatically focuses an element when it is inserted into the DOM. Useful for modals and search forms.
import { AfterViewInit, Directive, ElementRef, Input } from '@angular/core';
@Directive({
selector: '[appAutoFocus]',
standalone: true,
})
export class AutoFocusDirective implements AfterViewInit {
// Set to false to disable auto-focus conditionally
@Input() appAutoFocus = true;
constructor(private el: ElementRef<HTMLElement>) {}
ngAfterViewInit(): void {
if (this.appAutoFocus) {
// Use a microtask to focus after any animations complete
Promise.resolve().then(() => this.el.nativeElement.focus());
}
}
}
<!-- Auto-focus the search field when the modal opens -->
<input appAutoFocus type="text" placeholder="Search..." />
<!-- Conditionally enable auto-focus -->
<input [appAutoFocus]="shouldFocus" type="email" />
d. Permission control directive
An extension of the *appHasRole pattern from Section 4 that accepts multiple permission strings.
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';
@Directive({
selector: '[appHasPermission]',
standalone: true,
})
export class HasPermissionDirective {
@Input() set appHasPermission(permission: string | string[]) {
const permissions = Array.isArray(permission) ? permission : [permission];
this.vcr.clear();
if (permissions.some(p => this.authService.hasPermission(p))) {
this.vcr.createEmbeddedView(this.templateRef);
}
}
constructor(
private templateRef: TemplateRef<any>,
private vcr: ViewContainerRef,
private authService: AuthService,
) {}
}
<!-- Single permission -->
<button *appHasPermission="'article:delete'">Delete Article</button>
<!-- Multiple permissions (shown if user has ANY of them) -->
<div *appHasPermission="['article:edit', 'article:publish']">
Edit / Publish Menu
</div>
e. Numbers-only input directive
Blocks non-numeric keyboard input in a text field.
import { Directive, HostListener } from '@angular/core';
@Directive({
selector: '[appNumbersOnly]',
standalone: true,
})
export class NumbersOnlyDirective {
@HostListener('keypress', ['$event'])
onKeyPress(event: KeyboardEvent): void {
const allowedKeys = ['Backspace', 'Tab', 'Enter', 'ArrowLeft', 'ArrowRight', 'Delete'];
if (allowedKeys.includes(event.key)) {
return; // Allow control keys
}
if (!/^\d$/.test(event.key)) {
event.preventDefault(); // Block non-digits
}
}
@HostListener('paste', ['$event'])
onPaste(event: ClipboardEvent): void {
const pastedText = event.clipboardData?.getData('text') ?? '';
if (!/^\d+$/.test(pastedText)) {
event.preventDefault(); // Block non-numeric paste
}
}
}
<!-- Postal codes, phone numbers, etc. -->
<input appNumbersOnly type="text" placeholder="Zip code (digits only)" maxlength="5" />
<input appNumbersOnly type="text" placeholder="Phone number (digits only)" />
All practical directives above are implemented as standalone (standalone: true). Keeping each directive in its own file makes testing and reuse straightforward. Place shared directives in a shared/directives/ directory and re-export them through a barrel file (index.ts) as a best practice.