Angular Directives: Attribute, Structural, Custom — Everything You Need [2026]

Link copied
Angular Directives: Attribute, Structural, Custom — Everything You Need [2026]

Angular Directives: Attribute, Structural, Custom — Everything You Need [2026]

Components are directives with templates. Strip the template away and you have a directive — a class that attaches behavior to an existing element. Angular ships a handful of built-in directives (NgClass, NgStyle, NgModel) and gives you the same tools to write your own. This lesson explains attribute directives, structural directives, the built-ins worth knowing, and how to write a custom one.

This is lesson 2.9 of the Angular Tutorial, following lesson 2.8 on pipes. By the end you will be able to read and write either kind of directive, and know when one is the better tool than a component.

Three kinds of directive #

Angular recognizes three directive flavors:

Kind What it does Example
Component Directive + template Every @Component. The bulk of your app.
Attribute directive Changes the appearance or behavior of an element [ngClass], [hoverHighlight], [tooltip]
Structural directive Changes the structure (adds/removes elements) *ngIf (legacy), *ngFor (legacy), custom

Components are 90% of what you write. Attribute directives are reusable behaviors. Structural directives are template-rewriting macros — the most powerful and the least common in 2026 code, because @if @for @switch replaced the everyday cases.

Built-in attribute directives #

Three are still relevant in 2026:

NgClass — toggle multiple CSS classes #

<div [ngClass]="{ 'active': isActive(), 'error': hasError() }">...</div>

The equivalent without NgClass:

<div [class.active]="isActive()" [class.error]="hasError()">...</div>

For two or three classes, the native [class.X] syntax is cleaner. NgClass wins when the class list is dynamic — driven by a config object, computed from a signal, or coming from an array:

<div [ngClass]="theme().classes">...</div>

NgStyle — set inline styles #

Same story:

<div [ngStyle]="{ 'color': fg(), 'background': bg() }">...</div>

<!-- vs native -->
<div [style.color]="fg()" [style.background]="bg()">...</div>

Reach for NgStyle when the styles are dynamic or come from a config object. The native [style.X] syntax is shorter for the static-list case.

NgModel — two-way binding for inputs (Forms) #

<input [(ngModel)]="name" />

NgModel is the bridge between native form controls and component state. It is the heart of template-driven forms (lesson 4.3). For reactive forms (lesson 4.2) and signal forms (lesson 4.1), you do not need it.

What is NOT here anymore #

The old structural directives — NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault — were replaced by the @if @for @switch block syntax in v17 (lesson 2.2). Old code still uses them; new code should not. If you inherit a codebase full of *ngFor="let item of items; trackBy: trackFn", run ng generate @angular/core:control-flow for automated migration.

Writing a custom attribute directive #

Attribute directives attach behavior to existing elements. The minimal example — a directive that highlights on hover:

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

@Directive({
  selector: '[appHoverHighlight]',
})
export class HoverHighlightDirective {
  private el = inject(ElementRef<HTMLElement>);
  color = input('#fef3c7', { alias: 'appHoverHighlight' });

  @HostListener('mouseenter')
  onEnter() {
    this.el.nativeElement.style.background = this.color();
  }

  @HostListener('mouseleave')
  onLeave() {
    this.el.nativeElement.style.background = '';
  }
}

Usage in any component that imports HoverHighlightDirective:

<p appHoverHighlight>Hover me</p>
<p [appHoverHighlight]="'#bfdbfe'">Hover me, custom color</p>

The shape:

  1. selector is a CSS-style attribute selector. [appHoverHighlight] matches any element with that attribute.
  2. Inject ElementRef to access the host DOM element.
  3. @HostListener binds DOM events to methods. (The modern alternative is host: { '(mouseenter)': '...' } in metadata — lesson 2.12 covers host bindings in depth.)
  4. Inputs work exactly like components. The alias appHoverHighlight lets you write [appHoverHighlight]="'#bfdbfe'" — assigning to the selector attribute itself.

Writing a custom structural directive #

Structural directives rewrite the template. They use the * prefix syntax (sugar for <ng-template> — see lesson 2.10).

The * desugars: *appUnless="isLoggedIn()" is shorthand for wrapping the element in an <ng-template> that the directive controls.

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

@Directive({
  selector: '[appUnless]',
})
export class UnlessDirective {
  private templateRef = inject(TemplateRef<unknown>);
  private vcr = inject(ViewContainerRef);
  private condition = signal(true);

  @Input()
  set appUnless(value: boolean) {
    this.condition.set(value);
  }

  constructor() {
    effect(() => {
      this.vcr.clear();
      if (!this.condition()) {
        this.vcr.createEmbeddedView(this.templateRef);
      }
    });
  }
}

Usage:

<p *appUnless="isLoggedIn()">Please sign in.</p>

Structural-directive mechanics:

  1. Inject TemplateRef and ViewContainerRef. TemplateRef is the captured <ng-template>; ViewContainerRef is where you render copies of it.
  2. The directive controls when the template appears. Call createEmbeddedView() to add it; clear() to remove.
  3. The setter pattern catches input changes. Or use a signal-based @Input() with effect() (shown above).

In 2026 the practical truth is: you almost never write a custom structural directive. @if @for @switch cover the everyday cases; @let covers template-scoped derivations. Reach for a custom structural directive only for shared template-rewriting logic that the blocks cannot express — *appPermission="'admin'", *appFeatureFlag="'beta'", etc.

When to write a directive vs a component #

A mental model:

Need Right tool
Render markup with state Component
Attach behavior to existing markup (tooltip, focus trap, lazy-load image) Attribute directive
Wrap parent layout (router-outlet behavior, modal slot) Component with <ng-content>
Conditionally include/exclude elements based on shared rule Structural directive (or @if)
Encapsulate a chunk of UI with its own template Component
Reuse a DOM-mutation behavior across element types Attribute directive

When in doubt, start with a component. Switch to a directive if you find yourself wrapping every element in <my-thing> just to apply a behavior that the host element should already have.

Real-world directive examples #

A few directives that earn their keep:

Auto-focus on mount #

@Directive({ selector: '[appAutoFocus]' })
export class AutoFocusDirective {
  private el = inject(ElementRef<HTMLElement>);
  constructor() {
    afterNextRender(() => this.el.nativeElement.focus());
  }
}

Usage: <input appAutoFocus />. Drops the boilerplate of @ViewChild + ngAfterViewInit + nativeElement.focus().

Click outside #

@Directive({ selector: '[appClickOutside]' })
export class ClickOutsideDirective {
  private el = inject(ElementRef<HTMLElement>);
  outside = output<MouseEvent>();

  @HostListener('document:click', ['$event'])
  onDocClick(event: MouseEvent) {
    if (!this.el.nativeElement.contains(event.target as Node)) {
      this.outside.emit(event);
    }
  }
}

Usage: <div appClickOutside (outside)="close()">...</div>. The classic dropdown / modal dismissal pattern.

Permission check (structural) #

@Directive({ selector: '[appPermission]' })
export class PermissionDirective {
  private auth = inject(AuthService);
  private tpl = inject(TemplateRef<unknown>);
  private vcr = inject(ViewContainerRef);
  permission = input.required<string>({ alias: 'appPermission' });

  constructor() {
    effect(() => {
      this.vcr.clear();
      if (this.auth.hasPermission(this.permission())) {
        this.vcr.createEmbeddedView(this.tpl);
      }
    });
  }
}

Usage: <button *appPermission="'delete-users'" (click)="delete()">Delete</button>. The button does not render if the current user lacks the permission.

hostDirectives — composing directives in a component #

A component can apply directives to its own host element via the hostDirectives metadata:

@Component({
  selector: 'app-card',
  hostDirectives: [HoverHighlightDirective, AutoFocusDirective],
  template: `<ng-content />`,
})
export class CardComponent {}

Now every <app-card> has hover-highlight + auto-focus behavior, even though the directives are declared elsewhere. This is the cleanest composition pattern Angular has — better than the inheritance gymnastics older versions required.

Common gotchas #

Symptom Cause Fix
'appHoverHighlight' is not a known property of '<p>' Forgot to import the directive in the component Add HoverHighlightDirective to the component's imports array
Directive selector doesn't match expected elements Selector syntax mistake — used appHoverHighlight (component-style) instead of [appHoverHighlight] (attribute-style) Use the bracketed form for attribute directives
Custom structural directive doesn't recreate template on input change Used a plain field instead of a setter or signal Either use @Input set X(value) or migrate to input() signal + effect()
Directive's inject(TemplateRef) returns null The directive is on a regular element (<div appFoo>), not used as *appFoo Structural directives only work with * prefix; attribute directives don't need TemplateRef
host: events fire but @HostListener doesn't Both syntax forms are valid but you mixed them inconsistently Pick one — the modern host: { '(click)': '...' } is preferred for new directives

What's next #

Lesson 2.10 explains <ng-template> and <ng-container> — the building blocks structural directives are built on, plus the cleanest way to handle template fragments without an extra DOM element. Lesson 2.11 walks styling and ViewEncapsulation. Then lessons 2.12 and 2.13 cover host element metadata and signal queries.

Try it yourself #

A tooltip directive that handles delay and positioning — a real-world pattern:

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

@Directive({
  selector: '[appTooltip]',
  host: {
    '(mouseenter)': 'show()',
    '(mouseleave)': 'hide()',
  },
})
export class TooltipDirective {
  private el = inject(ElementRef<HTMLElement>);
  text = input.required<string>({ alias: 'appTooltip' });
  delay = input(200);

  private timer = signal<ReturnType<typeof setTimeout> | null>(null);
  private node = signal<HTMLDivElement | null>(null);

  show() {
    const t = setTimeout(() => {
      const div = document.createElement('div');
      div.textContent = this.text();
      div.style.cssText = 'position:fixed; background:#000; color:#fff; padding:6px 10px; border-radius:6px; font-size:12px';
      const r = this.el.nativeElement.getBoundingClientRect();
      div.style.left = r.left + 'px';
      div.style.top = (r.bottom + 6) + 'px';
      document.body.appendChild(div);
      this.node.set(div);
    }, this.delay());
    this.timer.set(t);
  }

  hide() {
    const t = this.timer();
    if (t) clearTimeout(t);
    this.node()?.remove();
    this.node.set(null);
  }
}

Usage: <button [appTooltip]="'Save the document'" [delay]="500">Save</button>. Production code would use Angular's OverlayModule or a real tooltip library — but the shape is the same.

YouWhen should I make something a directive vs a component with ng-content?
Claude · used get_best_practicesIf the host element already exists and you just need to add behavior, use an attribute directive — no extra wrapper, no extra DOM. If you need to provide markup around the content (a card with header + body + actions), use a component with <ng-content>. The smell test: “would I write <input> 100 times with my behavior, or <my-input><input></my-input>?” The directive lets the consumer use any element they want; the wrapper component imposes structure.

Lesson 2.10 picks up <ng-template> and <ng-container> — the directives' building blocks.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *