On this page

Custom directives and pipes

10 min read TextCh. 4 — Integration

Directives in Angular

Directives extend the behavior of HTML elements. There are two main types:

  • Attribute directives — Modify the appearance or behavior of an element
  • Structural directives — Modify the DOM structure (add/remove elements)

Attribute directives

They create reusable behavior that is applied to existing elements:

import { Directive, ElementRef, inject, input } from '@angular/core';

@Directive({
  selector: '[appTooltip]',
  host: {
    '(mouseenter)': 'show()',
    '(mouseleave)': 'hide()',
    '[attr.aria-label]': 'appTooltip()',
  },
})
export class TooltipDirective {
  readonly appTooltip = input.required<string>();

  show(): void { /* tooltip logic */ }
  hide(): void { /* hide tooltip */ }
}

Usage in the template:

<button [appTooltip]="'Save changes'">Save</button>

Structural directives

They modify the DOM structure. They use TemplateRef and ViewContainerRef:

import { Directive, TemplateRef, ViewContainerRef, inject, input, effect } from '@angular/core';

@Directive({ selector: '[appRepeat]' })
export class RepeatDirective {
  private readonly template = inject(TemplateRef);
  private readonly container = inject(ViewContainerRef);

  readonly appRepeat = input.required<number>();

  constructor() {
    effect(() => {
      this.container.clear();
      for (let i = 0; i < this.appRepeat(); i++) {
        this.container.createEmbeddedView(this.template);
      }
    });
  }
}

Pipes: transforming data in templates

Pipes transform data for presentation. They are used with the | operator in templates.

Built-in Angular pipes

Pipe Usage Example
date Format dates {{ date | date:'short' }}
currency Format currency {{ price | currency:'USD' }}
uppercase Uppercase {{ text | uppercase }}
lowercase Lowercase {{ text | lowercase }}
titlecase Capitalize {{ text | titlecase }}
number Format numbers {{ value | number:'1.2-2' }}
percent Percentage {{ ratio | percent }}
json Serialize to JSON {{ object | json }}
slice Extract portion {{ list | slice:0:5 }}
async Resolve Observable/Promise {{ obs$ | async }}

Creating a custom pipe

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'initials' })
export class InitialsPipe implements PipeTransform {
  transform(fullName: string): string {
    if (!fullName) return '';
    return fullName
      .split(' ')
      .map(word => word[0])
      .join('')
      .toUpperCase();
  }
}

Usage:

<span class="avatar">{{ user.name | initials }}</span>
<!-- "Carlos Morales" -> "CM" -->

Pipes with parameters

@Pipe({ name: 'filterBy' })
export class FilterByPipe implements PipeTransform {
  transform<T>(list: T[], field: keyof T, value: T[keyof T]): T[] {
    return list.filter(item => item[field] === value);
  }
}
@for (active of users | filterBy:'status':'active'; track active.id) {
  <span>{{ active.name }}</span>
}

Importing directives and pipes

In Angular 21, import directives and pipes directly in the component:

@Component({
  imports: [HighlightDirective, RelativeTimePipe],
  template: `
    <p appHighlight>{{ date | relativeTime }}</p>
  `,
})

Practice

  1. Create an attribute directive: Implement an appTooltip directive that shows floating text on hover over an element. Use the host property of the decorator instead of @HostListener.
  2. Build a custom pipe: Implement a timeAgo pipe that receives a date and returns a string like "5 min ago", "2 h ago", or "3 days ago".
  3. Create a pipe with parameters: Implement a highlight pipe that receives a text and a search term, and returns the text with the term wrapped in a <mark> tag.

In the next lesson, we will learn about Server-Side Rendering and pre-rendering to improve SEO and performance.

Pure vs impure pipes
Pure pipes (pure: true, the default) only recalculate when the input reference changes. Impure pipes (pure: false) recalculate on every change detection cycle. Prefer pure pipes.
host over HostListener
In Angular 21, use the host property of the @Directive decorator instead of @HostListener and @HostBinding. It is more declarative and works better with the AOT compiler.
import {
  Directive, Pipe, PipeTransform,
  ElementRef, TemplateRef, ViewContainerRef,
  inject, input, effect,
} from '@angular/core';
import { AuthService } from './auth.service';

// --- Attribute directive: highlight ---
@Directive({
  selector: '[appHighlight]',
  host: {
    '(mouseenter)': 'activate()',
    '(mouseleave)': 'deactivate()',
    '[style.transition]': '"background-color 0.3s ease"',
  },
})
export class HighlightDirective {
  private readonly el = inject(ElementRef);

  readonly appHighlight = input('var(--brand-amber)');

  activate(): void {
    this.el.nativeElement.style.backgroundColor = this.appHighlight();
  }

  deactivate(): void {
    this.el.nativeElement.style.backgroundColor = '';
  }
}

// --- Structural directive: role-based ---
@Directive({
  selector: '[appIfRole]',
})
export class IfRoleDirective {
  private readonly templateRef = inject(TemplateRef);
  private readonly viewContainer = inject(ViewContainerRef);

  readonly appIfRole = input.required<string>();

  constructor() {
    const auth = inject(AuthService);
    effect(() => {
      const requiredRole = this.appIfRole();
      const user = auth.user();
      if (user?.role === requiredRole) {
        this.viewContainer.createEmbeddedView(this.templateRef);
      } else {
        this.viewContainer.clear();
      }
    });
  }
}

// --- Pipe: relative time ---
@Pipe({
  name: 'relativeTime',
  pure: true,
})
export class RelativeTimePipe implements PipeTransform {
  transform(date: string | Date): string {
    const now = Date.now();
    const past = new Date(date).getTime();
    const diff = now - past;

    const minutes = Math.floor(diff / 60000);
    const hours = Math.floor(diff / 3600000);
    const days = Math.floor(diff / 86400000);

    if (minutes < 1) return 'Just now';
    if (minutes < 60) return `${minutes} min ago`;
    if (hours < 24) return `${hours} h ago`;
    if (days < 30) return `${days} days ago`;
    return new Date(date).toLocaleDateString('en');
  }
}

// --- Pipe: truncate text ---
@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
  transform(text: string, length = 100): string {
    if (!text || text.length <= length) return text;
    return text.substring(0, length).trimEnd() + '...';
  }
}