AngularJSCustom Directives

Custom Directives in Angular

Custom directives are one of Angular's most powerful extension points. They let you package reusable DOM behaviour, add accessibility attributes, enforce interaction patterns, and create micro-frameworks — all without writing a component.

This page covers the full lifecycle of writing a custom directive: from a simple attribute directive through a full custom structural directive, including testing patterns.

Generating a Directive

Bash
# Angular CLI creates the directive + spec file
ng generate directive shared/highlight
# or short form
ng g d shared/highlight

TS
// Generated: src/app/shared/highlight.directive.ts
import { Directive } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  standalone: true,
})
export class HighlightDirective {}
Example 1: Simple Hover Highlight

The classic starting example — change the background colour when the user hovers over an element.

TS
import {
  Directive,
  ElementRef,
  HostListener,
  Input,
  OnInit,
} from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  standalone: true,
})
export class HighlightDirective implements OnInit {
  @Input('appHighlight') hoverColor = '#ffeb3b';
  @Input() defaultBg = '';

  constructor(private el: ElementRef<HTMLElement>) {}

  ngOnInit() {
    this.el.nativeElement.style.transition = 'background-color 0.2s';
    this.el.nativeElement.style.backgroundColor = this.defaultBg;
  }

  @HostListener('mouseenter')
  onMouseEnter() {
    this.el.nativeElement.style.backgroundColor = this.hoverColor;
  }

  @HostListener('mouseleave')
  onMouseLeave() {
    this.el.nativeElement.style.backgroundColor = this.defaultBg;
  }
}

HTML
<p appHighlight>Highlights yellow on hover (default)</p>
<p appHighlight="lightblue">Highlights blue on hover</p>
<p [appHighlight]="theme.accentColor" defaultBg="#f5f5f5">Dynamic color</p>
Example 2: Click-to-Copy Directive

A practical directive that copies an element's text (or a specified value) to the clipboard when clicked.

TS
import {
  Directive,
  HostListener,
  Input,
  Output,
  EventEmitter,
  ElementRef,
} from '@angular/core';

@Directive({
  selector: '[appCopyToClipboard]',
  standalone: true,
})
export class CopyToClipboardDirective {
  // If provided, copies this value; otherwise copies the element's text
  @Input('appCopyToClipboard') copyText = '';

  @Output() copied = new EventEmitter<string>();
  @Output() copyFailed = new EventEmitter<Error>();

  constructor(private el: ElementRef<HTMLElement>) {}

  @HostListener('click')
  async onClick() {
    const text = this.copyText || this.el.nativeElement.innerText;
    try {
      await navigator.clipboard.writeText(text);
      this.copied.emit(text);
    } catch (err) {
      this.copyFailed.emit(err as Error);
    }
  }
}

HTML
<!-- Copies the element's own text -->
<code appCopyToClipboard (copied)="showToast('Copied!')">npm install @angular/core</code>

<!-- Copies a specific value -->
<button [appCopyToClipboard]="apiKey" (copied)="onCopied()">Copy API Key</button>
Example 3: Lazy Image Loading

A directive that uses the IntersectionObserver API to only load an image when it enters the viewport.

TS
import {
  Directive,
  ElementRef,
  Input,
  OnInit,
  OnDestroy,
} from '@angular/core';

@Directive({
  selector: 'img[appLazyLoad]',
  standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
  @Input('appLazyLoad') lazySrc = '';
  @Input() placeholder = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E';

  private observer!: IntersectionObserver;

  constructor(private el: ElementRef<HTMLImageElement>) {}

  ngOnInit() {
    const img = this.el.nativeElement;
    img.src = this.placeholder;

    this.observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          img.src = this.lazySrc;
          img.onload = () => img.classList.add('loaded');
          this.observer.unobserve(img);
        }
      },
      { threshold: 0.1 },
    );

    this.observer.observe(img);
  }

  ngOnDestroy() {
    this.observer.disconnect();
  }
}

HTML
<!-- Only loads the real image when it scrolls into view -->
<img [appLazyLoad]="product.imageUrl" [alt]="product.name" />
Tip
Native lazy loading (\`loading="lazy"\`) handles most cases, but a custom directive like this gives you full control — progress events, placeholder transitions, retry logic, etc.
Example 4: Debounced Input Directive

Debounce a text input so a search or API call is only triggered after the user stops typing.

TS
import {
  Directive,
  EventEmitter,
  HostListener,
  Input,
  OnDestroy,
  Output,
} from '@angular/core';

@Directive({
  selector: '[appDebounce]',
  standalone: true,
})
export class DebounceDirective implements OnDestroy {
  @Input() debounceTime = 300;
  @Output() debounced = new EventEmitter<string>();

  private timer: ReturnType<typeof setTimeout> | null = null;

  @HostListener('input', ['$event'])
  onInput(event: Event) {
    if (this.timer) clearTimeout(this.timer);
    this.timer = setTimeout(() => {
      this.debounced.emit((event.target as HTMLInputElement).value);
    }, this.debounceTime);
  }

  ngOnDestroy() {
    if (this.timer) clearTimeout(this.timer);
  }
}

TS
@Component({
  selector: 'app-search',
  standalone: true,
  imports: [DebounceDirective],
  template: `
    <input
      [debounceTime]="500"
      (debounced)="onSearch($event)"
      placeholder="Search..."
    />
    <p>Last search: {{ lastQuery }}</p>
  `,
})
export class SearchComponent {
  lastQuery = '';

  onSearch(query: string) {
    this.lastQuery = query;
    // Call your API here
    console.log('Searching:', query);
  }
}
Example 5: Custom Structural Directive

A custom structural directive that renders its template only if the user has a required permission — a common access-control pattern.

TS
// has-permission.directive.ts
import {
  Directive,
  Input,
  OnInit,
  TemplateRef,
  ViewContainerRef,
  inject,
} from '@angular/core';
import { AuthService } from './auth.service';

@Directive({
  selector: '[appHasPermission]',
  standalone: true,
})
export class HasPermissionDirective implements OnInit {
  @Input('appHasPermission') permission = '';

  private templateRef = inject(TemplateRef);
  private viewContainer = inject(ViewContainerRef);
  private authService  = inject(AuthService);

  ngOnInit() {
    if (this.authService.hasPermission(this.permission)) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}

HTML
<!-- Only renders if the current user has 'delete:posts' permission -->
<button *appHasPermission="'delete:posts'" (click)="deletePost()">Delete</button>

<!-- Admin-only section -->
<div *appHasPermission="'manage:users'">
  <app-user-management-panel />
</div>
Directive Composition API (Angular 15+)

The Directive Composition API lets you apply multiple directives to a component's host element from within the @Component decorator, reducing the need for a wrapper element.

TS
import { Component, Directive, HostBinding, HostListener } from '@angular/core';

// Reusable micro-directives
@Directive({ standalone: true })
export class DisabledStateDirective {
  @HostBinding('attr.aria-disabled') @Input() disabled = false;
  @HostBinding('class.disabled') get cls() { return this.disabled; }
}

@Directive({ standalone: true })
export class FocusRingDirective {
  @HostBinding('class.focus-visible') hasFocus = false;
  @HostListener('focusin')  onFocusIn()  { this.hasFocus = true; }
  @HostListener('focusout') onFocusOut() { this.hasFocus = false; }
}

// Compose them onto a component's host
@Component({
  selector: 'app-button',
  standalone: true,
  hostDirectives: [
    { directive: DisabledStateDirective, inputs: ['disabled'] },
    FocusRingDirective,
  ],
  template: `<ng-content />`,
})
export class ButtonComponent {}
Note
Host directives are applied transparently — users of \`app-button\` can use \`[disabled]\` and will automatically get both the ARIA attribute and the CSS class without the component doing anything extra.
Testing a Custom Directive

TS
// highlight.directive.spec.ts
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';

@Component({
  standalone: true,
  imports: [HighlightDirective],
  template: `<p appHighlight="cyan">Hover me</p>`,
})
class TestHostComponent {}

describe('HighlightDirective', () => {
  let fixture: ComponentFixture<TestHostComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(TestHostComponent);
    fixture.detectChanges();
  });

  it('sets background on mouseenter', () => {
    const p = fixture.debugElement.query(By.css('p')).nativeElement;
    p.dispatchEvent(new Event('mouseenter'));
    fixture.detectChanges();
    expect(p.style.backgroundColor).toBe('cyan');
  });

  it('clears background on mouseleave', () => {
    const p = fixture.debugElement.query(By.css('p')).nativeElement;
    p.dispatchEvent(new Event('mouseenter'));
    p.dispatchEvent(new Event('mouseleave'));
    fixture.detectChanges();
    expect(p.style.backgroundColor).toBe('');
  });
});
Tip
Testing directives via a host component (rather than testing the directive class directly) gives you confidence that the directive works correctly when applied to a real element.
Directive Design Checklist
  • Give the directive a clear, action-describing name (appCopyToClipboard, appLazyLoad).

  • Use a consistent app prefix to avoid name collisions with third-party directives.

  • Expose @Input() properties for configuration so the directive can be reused.

  • Emit @Output() events to communicate results back to the parent component.

  • Always clean up in ngOnDestroy: clear timers, unsubscribe, disconnect observers.

  • Use Renderer2 for DOM manipulation to keep the directive SSR-safe.

  • Mark directives as standalone: true for modern Angular projects.

  • Write tests via a TestHostComponent — test behaviour, not implementation details.

Summary

Directive Type

When to Use

Attribute directive

Add behaviour/styling to an existing element (hover, copy, lazy load)

Structural directive

Conditionally render or repeat a template (permission guard, repeat N times)

Host directive (composition)

Bundle behaviour onto a component's host element without a wrapper