Docs
/
Angular
Chapter 3
03 — Directives & Pipes
Directives Overview
Directives add behavior to DOM elements. There are three types:
| Type | Purpose | Example |
|---|---|---|
| Component | Directive with a template | <app-card /> |
| Structural | Change DOM layout (add/remove elements) | @if, @for, *ngIf, *ngFor |
| Attribute | Change appearance or behavior | [ngClass], [ngStyle], custom |
Built-in Attribute Directives
ngClass — Dynamic CSS Classes
<!-- Object syntax -->
<div [ngClass]="{ active: isActive, 'text-danger': hasError, disabled: !enabled }">
<!-- Array syntax -->
<div [ngClass]="['base-class', isAdmin ? 'admin' : 'user']">
<!-- String syntax -->
<div [ngClass]="currentClasses">
currentClasses = 'bold italic underline';
ngStyle — Dynamic Inline Styles
<div [ngStyle]="{
'font-size': fontSize + 'px',
'background-color': isHighlighted ? 'yellow' : 'transparent',
'width.%': widthPercent
}">
> Prefer [class.xxx] and [style.xxx] for single bindings — ngClass/ngStyle for multiple.
ngModel — Two-Way Binding (Forms)
// Requires FormsModule in imports
import { FormsModule } from '@angular/forms';
@Component({
imports: [FormsModule],
template: `<input [(ngModel)]="query"> <p>{{ query }}</p>`,
})
export class SearchComponent {
query = '';
}
Creating a Custom Attribute Directive
Highlight Directive
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true,
})
export class HighlightDirective {
@Input() appHighlight = 'yellow'; // Color input (same name as selector)
@Input() highlightText = 'black'; // Text color
constructor(private el: ElementRef<HTMLElement>) {}
@HostListener('mouseenter')
onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.appHighlight;
this.el.nativeElement.style.color = this.highlightText;
}
@HostListener('mouseleave')
onMouseLeave() {
this.el.nativeElement.style.backgroundColor = '';
this.el.nativeElement.style.color = '';
}
}
<p appHighlight>Default yellow highlight</p>
<p [appHighlight]="'lightblue'" highlightText="white">Blue highlight</p>
Click Outside Directive
@Directive({
selector: '[appClickOutside]',
standalone: true,
})
export class ClickOutsideDirective {
@Output() appClickOutside = new EventEmitter<void>();
constructor(private el: ElementRef) {}
@HostListener('document:click', ['$event.target'])
onClick(target: HTMLElement) {
if (!this.el.nativeElement.contains(target)) {
this.appClickOutside.emit();
}
}
}
<div class="dropdown" (appClickOutside)="closeDropdown()">
<!-- dropdown content -->
</div>
Debounce Input Directive
@Directive({
selector: '[appDebounce]',
standalone: true,
})
export class DebounceDirective implements OnInit, OnDestroy {
@Input() appDebounce = 300; // Debounce time in ms
@Output() debounced = new EventEmitter<string>();
private destroy$ = new Subject<void>();
constructor(private el: ElementRef<HTMLInputElement>) {}
ngOnInit() {
fromEvent(this.el.nativeElement, 'input')
.pipe(
debounceTime(this.appDebounce),
map((e: any) => e.target.value),
distinctUntilChanged(),
takeUntil(this.destroy$),
)
.subscribe(value => this.debounced.emit(value));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
<input [appDebounce]="500" (debounced)="search($event)" placeholder="Search...">
Structural Directives (Legacy *ngIf / *ngFor)
> In Angular 17+, prefer the new control flow (@if, @for). These still work for older codebases.
<!-- *ngIf -->
<div *ngIf="user; else noUser">
Welcome, {{ user.name }}
</div>
<ng-template #noUser>
<p>Please log in</p>
</ng-template>
<!-- *ngFor -->
<li *ngFor="let item of items; index as i; trackBy: trackById">
{{ i + 1 }}. {{ item.name }}
</li>
<!-- *ngSwitch -->
<div [ngSwitch]="role">
<p *ngSwitchCase="'admin'">Admin Panel</p>
<p *ngSwitchCase="'user'">User Dashboard</p>
<p *ngSwitchDefault>Guest View</p>
</div>
Pipes Overview
Pipes transform displayed values in templates. They don't change the underlying data.
{{ value | pipeName:arg1:arg2 }}
Built-in Pipes
| Pipe | Example | Output |
|---|---|---|
uppercase | {{ 'hello' | uppercase }} | HELLO |
lowercase | {{ 'HELLO' | lowercase }} | hello |
titlecase | {{ 'hello world' | titlecase }} | Hello World |
date | {{ today | date:'short' }} | 1/15/24, 9:30 AM |
currency | {{ 99.5 | currency:'USD' }} | $99.50 |
decimal | {{ 3.14159 | number:'1.2-2' }} | 3.14 |
percent | {{ 0.85 | percent }} | 85% |
json | {{ obj | json }} | JSON string |
async | {{ obs$ | async }} | Latest emitted value |
slice | {{ items | slice:0:5 }} | First 5 items |
keyvalue | {{ map | keyvalue }} | Array of {key, value} |
Date Pipe Formats
{{ today | date:'short' }} <!-- 1/15/24, 9:30 AM -->
{{ today | date:'medium' }} <!-- Jan 15, 2024, 9:30:00 AM -->
{{ today | date:'fullDate' }} <!-- Monday, January 15, 2024 -->
{{ today | date:'yyyy-MM-dd' }} <!-- 2024-01-15 -->
{{ today | date:'HH:mm' }} <!-- 09:30 -->
{{ today | date:'EEEE, MMMM d' }} <!-- Monday, January 15 -->
Currency Pipe
{{ 1499.99 | currency }} <!-- $1,499.99 (default USD) -->
{{ 1499.99 | currency:'EUR' }} <!-- €1,499.99 -->
{{ 1499.99 | currency:'GBP':'code' }} <!-- GBP1,499.99 -->
{{ 1499.99 | currency:'INR':'symbol':'1.0-0' }} <!-- ₹1,500 -->
Async Pipe (Very Important)
The async pipe subscribes and unsubscribes automatically — no need for ngOnDestroy.
@Component({
imports: [AsyncPipe],
template: `
@if (user$ | async; as user) {
<h1>{{ user.name }}</h1>
} @else {
<p>Loading...</p>
}
@for (item of items$ | async ?? []; track item.id) {
<app-item [data]="item" />
}
`,
})
export class ProfileComponent {
user$ = this.userService.getProfile();
items$ = this.itemService.getAll();
constructor(
private userService: UserService,
private itemService: ItemService,
) {}
}
Chaining Pipes
{{ user.createdAt | date:'longDate' | uppercase }}
<!-- JANUARY 15, 2024 -->
{{ users | slice:0:5 | json }}
Creating Custom Pipes
Truncate Pipe
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate',
standalone: true,
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 50, trail = '...'): string {
if (!value) return '';
return value.length > limit ? value.substring(0, limit) + trail : value;
}
}
{{ longText | truncate:100:'…' }}
Time Ago Pipe
@Pipe({ name: 'timeAgo', standalone: true })
export class TimeAgoPipe implements PipeTransform {
transform(value: string | Date): string {
const date = new Date(value);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
const intervals: [number, string][] = [
[31536000, 'year'], [2592000, 'month'], [86400, 'day'],
[3600, 'hour'], [60, 'minute'], [1, 'second'],
];
for (const [secs, label] of intervals) {
const count = Math.floor(seconds / secs);
if (count >= 1) return `${count} ${label}${count > 1 ? 's' : ''} ago`;
}
return 'just now';
}
}
{{ comment.createdAt | timeAgo }} <!-- "3 hours ago" -->
File Size Pipe
@Pipe({ name: 'fileSize', standalone: true })
export class FileSizePipe implements PipeTransform {
transform(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i];
}
}
Pure vs Impure Pipes
// Pure (default) — only recalculates when input reference changes
@Pipe({ name: 'filter', pure: true }) // ✅ Performant
// Impure — recalculates on every change detection cycle
@Pipe({ name: 'filter', pure: false }) // ⚠️ Expensive — use sparingly
> Rule: Keep pipes pure. If you need impure behavior, consider using a computed signal or a method in the component instead.
Key Takeaways
- Use
[class.xxx]/[style.xxx]for single bindings;ngClass/ngStylefor multiple - Custom attribute directives use
@HostListenerfor events andElementReffor DOM access - The
asyncpipe is essential — it auto-subscribes and auto-unsubscribes from observables - Always keep pipes pure for performance — avoid impure pipes on large lists
- Prefer the new control flow (
@if,@for) over*ngIf,*ngForin Angular 17+ - Custom pipes are great for formatting (truncate, timeAgo, fileSize, currency)