Tutorials Logic, IN info@tutorialslogic.com

Angular Directives: Attribute, Structural and Composition

Directive Matching and Runtime Behavior

Attribute directives add behavior to an existing host. Structural directives create and remove embedded views. Angular built-in control-flow blocks are separate template syntax. Current directive APIs include signal inputs, host metadata, TemplateRef, ViewContainerRef, and hostDirectives.

During template creation, Angular matches directive selectors against each host element and creates one instance for every match. That instance can expose inputs and outputs, bind host properties or events, provide dependencies, and run lifecycle cleanup.

A component is a directive with a template. An attribute directive changes an existing element or component. A structural directive controls an embedded template and therefore changes which DOM nodes exist. These categories describe ownership, not visual appearance.

The built-in @if, @for, and @switch blocks are Angular template syntax, not directives. They need no import. Use them for normal conditions, iteration, and branching; create a structural directive only for reusable rendering behavior that those blocks do not express.

Tool Ownership Use it for
Component Own host plus template A card, dialog, page, form field, or visible UI unit
Attribute directive Behavior on an existing host Focus, keyboard, permissions, interaction, or reusable state styling
Structural directive An ng-template and embedded views Application-specific conditional or contextual rendering
@if, @for, @switch Built-in template control flow Normal conditions, loops, and alternatives
Class, style, or property binding One value on one element A clear local change with no reusable behavior
  • Use a directive when the behavior has a clear name, contract, and reuse boundary.
  • Keep business state in components or services; let a directive own host behavior.
  • Import a standalone directive in every standalone component whose template uses it.
  • Treat the selector, inputs, outputs, and host bindings as the public API.

Selectors, Inputs and Host Bindings

The selector decides where Angular creates the directive. Attribute selectors such as [appHighlight] are the normal choice because they add behavior without claiming an element name. Prefix application selectors to avoid collisions with native attributes and library directives.

Signal inputs configure each instance and update when parent bindings change. Inputs can be optional, required, aliased, or transformed. Use a required input only when the directive has no useful default.

Use the decorator host object for host properties, ARIA or data attributes, classes, styles, CSS custom properties, and events. Angular 22 recommends host metadata over @HostBinding and @HostListener, which remain mainly for compatibility.

A Current Attribute Directive

appHighlight accepts its color through an aliased signal input. Host metadata applies the class, style, data attribute, and pointer listeners without nativeElement mutation or tag-specific assumptions.

highlight.directive.ts

highlight.directive.ts
import { Directive, computed, input, signal } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  host: {
    '[class.is-highlighted]': 'hovered()',
    '[style.background-color]': 'background()',
    '[attr.data-highlighted]': 'hovered() ? "true" : null',
    '(mouseenter)': 'show()',
    '(mouseleave)': 'hide()'
  }
})
export class HighlightDirective {
  readonly color = input('#fff3a3', { alias: 'appHighlight' });
  readonly hovered = signal(false);
  readonly background = computed(() =>
    this.hovered() ? this.color() : 'transparent'
  );

  show(): void { this.hovered.set(true); }
  hide(): void { this.hovered.set(false); }
}

The appHighlight selector value configures color while the class uses the descriptive property name color. Returning null removes the data attribute.

Import and use the directive

Import and use the directive
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';

@Component({
  selector: 'app-help',
  imports: [HighlightDirective],
  template: `
    <p appHighlight="#dff7ff">Hover for the custom color.</p>
    <button appHighlight>Default highlight</button>
  `
})
export class HelpComponent {}

Standalone is the default for current Angular declarations. The directive still belongs in the consuming component imports.

Host Binding Rules

Static host entries use plain keys such as role. Dynamic entries use binding syntax such as [attr.aria-expanded], [class.open], [style.width.px], or (keydown.escape). Update accessibility state together with visual state.

A consumer may bind the same host property. Avoid surprising collisions by documenting host behavior and exposing configuration. If Angular bindings cannot express an imperative write, isolate it, account for server rendering, and use Renderer2 where appropriate.

  • Use [class.name] or [style.property] for one class or style.
  • Use attr. for ARIA and data attributes because they are attributes, not ordinary properties.
  • Keep long-running work out of host expressions; call small methods or read prepared state.
  • Use document:, window:, or body: event targets only for genuinely global behavior.

Built-in Attribute Directives

NgClass adds and removes multiple CSS classes from a string, array, or object. NgStyle applies a map of inline styles. Both come from @angular/common and must be imported by a standalone component that uses them.

Prefer a direct class binding for one boolean class and a direct style binding for one value. Use NgClass or NgStyle when several values form one readable configuration object. Keep durable presentation rules in CSS classes; reserve NgStyle for values calculated at runtime.

NgModel is also an attribute directive, but its form contract belongs in the forms lesson. NgOptimizedImage is a specialized image directive. Built-in directives use the same public Angular APIs available to custom directives.

Requirement Preferred syntax
Toggle one class [class.selected]="selected()"
Set one style [style.width.px]="width()"
Apply several conditional classes [ngClass]="classMap()"
Apply several calculated styles [ngStyle]="styleMap()"
Reuse interaction across templates A custom attribute directive

Direct bindings and NgClass

Direct bindings and NgClass
import { NgClass } from '@angular/common';
import { Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-sync-status',
  imports: [NgClass],
  template: `
    <p
      [class.is-online]="online()"
      [style.opacity]="online() ? 1 : 0.65"
      [ngClass]="statusClasses()"
    >
      {{ online() ? 'Connected' : 'Offline' }}
    </p>
  `
})
export class SyncStatusComponent {
  readonly online = signal(false);
  readonly warning = signal(true);
  readonly statusClasses = computed(() => ({
    'has-warning': this.warning(),
    'is-compact': true
  }));
}

The one online class and opacity use direct bindings. Related warning and density classes share one NgClass object.

Custom Directive Contracts

Keep each directive responsible for one host behavior. Name inputs after configuration, emit outputs for meaningful behavior events, and inject services only when the behavior depends on application policy. Unrelated page state belongs in a component or service.

Directives participate in dependency injection and lifecycle hooks. They can provide a token, inject a peer directive, and clean up resources with DestroyRef. Use takeUntilDestroyed for observable subscriptions started by the directive.

exportAs publishes a directive instance to a template reference variable. Use it when a template genuinely needs a small imperative API. Inputs and outputs remain the default because they keep data flow explicit.

Inputs, Outputs and exportAs

TooltipDirective requires tooltip text, emits a close event, and exports open and close methods. Its host bindings expose interaction state in the DOM.

tooltip.directive.ts

tooltip.directive.ts
import { Directive, input, output, signal } from '@angular/core';

@Directive({
  selector: '[appTooltip]',
  exportAs: 'appTooltip',
  host: {
    '[attr.aria-label]': 'text()',
    '[attr.data-tooltip-open]': 'openState() ? "true" : null',
    '(focus)': 'open()',
    '(blur)': 'close()',
    '(keydown.escape)': 'close()'
  }
})
export class TooltipDirective {
  readonly text = input.required<string>({ alias: 'appTooltip' });
  readonly closed = output<void>();
  readonly openState = signal(false);

  open(): void { this.openState.set(true); }
  close(): void {
    if (!this.openState()) return;
    this.openState.set(false);
    this.closed.emit();
  }
}

Template reference API

Template reference API
<button
  appTooltip="Saves the current draft"
  #tip="appTooltip"
  (closed)="recordTooltipClose()"
  (click)="tip.close()"
>
  Save
</button>

The variable resolves to TooltipDirective because exportAs matches appTooltip. A large exported API usually means the behavior should become a component.

DOM, Cleanup and Security

ElementRef is useful for focus, measurement, observers, or a DOM-only library. It is not the first tool for classes, styles, attributes, or events because host bindings express those operations directly.

Never insert untrusted HTML through nativeElement.innerHTML. Manual writes can bypass Angular security behavior. Code that may run during server rendering must not assume window, document, layout measurements, or browser-only constructors exist.

  • Move focus only when the UX requires it and the host is focusable.
  • Release observers, timers, third-party instances, and global listeners with DestroyRef.
  • Keep layout reads and writes out of frequently evaluated host expressions.
  • Test keyboard and screen-reader semantics, not only CSS appearance.

Structural Directives and Control Flow

A structural directive is attached to an ng-template and decides when or how many embedded views Angular creates. TemplateRef describes the content; ViewContainerRef represents the insertion location.

The asterisk form is shorthand. Angular rewrites an element carrying *appUnless into an ng-template carrying the directive, with the original element inside. One shorthand expression creates one implicit ng-template, so only one structural directive can occupy an element. Use ng-container layers to make nesting explicit without adding DOM wrappers.

Use @if and @for for normal conditions and lists. NgIf, NgFor, and NgSwitch are deprecated in Angular 22. Create a structural directive for domain behavior such as permission-aware rendering, data selection with context, or view orchestration.

Build appUnless

The appUnless setter creates or clears its embedded view when the condition changes. The hasView flag prevents duplicate content when repeated values have the same truthiness.

unless.directive.ts

unless.directive.ts
import {
  Directive, Input, TemplateRef, ViewContainerRef, inject
} from '@angular/core';

@Directive({ selector: '[appUnless]' })
export class UnlessDirective {
  private readonly template = inject(TemplateRef<unknown>);
  private readonly container = inject(ViewContainerRef);
  private hasView = false;

  @Input()
  set appUnless(condition: boolean) {
    if (!condition && !this.hasView) {
      this.container.createEmbeddedView(this.template);
      this.hasView = true;
    } else if (condition && this.hasView) {
      this.container.clear();
      this.hasView = false;
    }
  }
}

Shorthand and expanded form

Shorthand and expanded form
<p *appUnless="accountLocked()">
  Account actions are available.
</p>

<ng-template [appUnless]="accountLocked()">
  <p>Account actions are available.</p>
</ng-template>

The directive lives on ng-template after expansion. It creates the paragraph only while accountLocked() is false.

Microsyntax and Template Context

For selector [select], *select="let item; from: source" maps from to selectFrom and maps let item to the context property $implicit. Prefix related inputs with the selector so this transformation stays predictable.

Pass exported values in the context object supplied to createEmbeddedView. Add ngTemplateContextGuard for typed generic context and an ngTemplateGuard_input member when the directive narrows a bound expression.

Shorthand Expanded meaning
*select="source" [select]="source" on ng-template
from: source [selectFrom]="source"
let item let-item="$implicit"
value as local A local variable for an exported context value
  • Use ng-container to layer structural behaviors.
  • Clear or reuse views deliberately; uncontrolled createEmbeddedView calls duplicate DOM.
  • Document each exported context key and TypeScript type.
  • Do not recreate @if or @for without a domain-specific reason.

Directive Composition

The directive composition API applies reusable behavior through hostDirectives on a component or another directive. Angular applies host directives statically at compile time to the same host element, and ignores their selectors in this role.

Host directive inputs and outputs are private by default. Expose only members that belong in the composed public API, and alias them when needed. A host directive must be standalone; current Angular declarations are standalone by default unless standalone: false is set.

Host directives run construction, lifecycle, and host bindings before the type that composes them. The composing component can therefore override host bindings intentionally. Composition is static, so it cannot add or remove behavior at runtime.

  • Compose stable primitives such as focus management, menu interaction, or disabled state.
  • Expose the smallest useful subset of inputs and outputs.
  • Check host-binding collisions when behaviors share an element.
  • Prefer composition to inheritance when types share behavior but not identity.
  • Do not expect hostDirectives to switch from a runtime condition.

Compose menu behavior

Compose menu behavior
import { Component, Directive, input, output } from '@angular/core';

@Directive({
  host: {
    'role': 'menu',
    '[attr.data-menu-id]': 'menuId()',
    '(keydown.escape)': 'close()'
  }
})
export class MenuBehavior {
  readonly menuId = input.required<string>();
  readonly menuClosed = output<void>();
  close(): void { this.menuClosed.emit(); }
}

@Component({
  selector: 'admin-menu',
  template: `<button role="menuitem">Users</button>`,
  hostDirectives: [{
    directive: MenuBehavior,
    inputs: ['menuId: behaviorId'],
    outputs: ['menuClosed: closed']
  }]
})
export class AdminMenu {}

Consumers bind behaviorId and closed on admin-menu. The MenuBehavior selector is irrelevant because hostDirectives applies it explicitly.

Directive Design Decisions

Use a local binding for a single, readable host value. Create a directive when a named behavior repeats, needs dependency injection or lifecycle cleanup, or must be composed into several hosts.

A directive should rely on its public contract, not component private fields or undocumented child markup. If behavior needs substantial markup, projection, layout, and many interaction states, a component gives it a clearer boundary.

A permission directive can hide or disable controls, but authorization must still be enforced by the server. Interface behavior is never a security boundary.

Situation Choose Why
One element toggles one class Class binding The local behavior is already clear.
Hosts share focus and keyboard rules Attribute directive The behavior has a reusable host contract.
A feature owns markup and structure Component It needs a view boundary.
Normal condition or list @if or @for Built-in control flow directly expresses it.
Rendering exports reusable context Structural directive It needs a named embedded-view abstraction.
A component always needs host behaviors hostDirectives Static composition avoids repeated annotations.
  • Prefer semantic HTML before adding directive-driven roles or keyboard behavior.
  • Keep selectors and public aliases stable; templates depend on them as APIs.
  • Avoid directives whose effects are invisible from their names.
  • Keep updates deterministic and clean up external resources.
  • Verify browser, SSR, accessibility, and security assumptions.

Directive Testing and Diagnosis

Test an attribute directive through a small host component. This covers selector matching, input binding, host events, and rendered DOM together. Include default and custom input cases.

For structural directives, assert embedded content before and after input changes. For composition, test public aliases and resulting host behavior rather than private implementation details.

An unknown-property error usually means a missing import or mismatched selector or input. Duplicate views point to unmanaged ViewContainerRef creation. Stale behavior after navigation points to missing cleanup.

Attribute Directive Test

highlight.directive.spec.ts

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

@Component({
  imports: [HighlightDirective],
  template: `<p appHighlight="#dff7ff">Read me</p>`
})
class TestHost {}

it('updates the host through pointer events', () => {
  const fixture = TestBed.createComponent(TestHost);
  fixture.detectChanges();
  const paragraph = fixture.debugElement.query(By.css('p'));

  paragraph.triggerEventHandler('mouseenter');
  fixture.detectChanges();
  expect(paragraph.nativeElement.classList.contains('is-highlighted'))
    .toBeTrue();

  paragraph.triggerEventHandler('mouseleave');
  fixture.detectChanges();
  expect(paragraph.nativeElement.classList.contains('is-highlighted'))
    .toBeFalse();
});

The test observes public DOM effects. Inject the directive from the debug element only when its class API needs direct verification.

Troubleshooting Map

Symptom Likely cause Check
Can't bind to appHighlight Missing import or wrong alias Import the directive; compare selector and input alias.
Constructor never runs Selector mismatch Inspect template spelling and selector form.
Host state is stale Binding reads wrong state Trace the signal or input and host expression.
Content appears twice View created without reuse logic Track view existence and repeated inputs.
Composed input is unknown It was not exposed Add it to hostDirectives and verify the alias.
SSR fails on window or layout Browser-only access Use a platform-aware boundary or render callback.
Behavior survives navigation Resource was not released Register DestroyRef cleanup and test destruction.
Confirm the page outcome

Directive Knowledge Check

8 checks
  • I can distinguish components, attribute directives, structural directives, and built-in control-flow blocks.
  • I can choose a direct binding, NgClass or NgStyle, a directive, or a component.
  • I can define a prefixed selector and configure optional or required signal inputs.
  • I can bind host properties, attributes, classes, styles, and events through host metadata.
  • I can build a structural directive with TemplateRef and ViewContainerRef without duplicate views.
  • I understand asterisk expansion, microsyntax mapping, context, and one structural directive per element.
  • I can compose static behavior and expose or alias host directive inputs and outputs.
  • I can test and diagnose imports, selectors, host state, cleanup, and SSR failures.

Try this next

Angular Directives Skill Drills

0 of 2 completed

  1. Create appPress with keyboard activation, disabled state, an ARIA attribute, a signal input, and a pressed output. Test pointer and keyboard paths. Prefer a native button when it already satisfies the requirement.
  2. Create *appLoad="let value; from: source" with loading, success, and empty behavior. Export $implicit and add context typing. Map from to appLoadFrom, manage each view deliberately, and test source changes.

Directive Design Questions

No. They are built-in template control-flow blocks and require no directive import. Use them for ordinary conditions, iteration, and branching; NgIf and NgFor are deprecated for new code. A custom structural directive remains useful for reusable application-specific rendering behavior. Because asterisk shorthand creates one implicit ng-template, place multiple structural behaviors on nested ng-container elements.

Use a component when it owns markup and a view boundary. Use an attribute directive when existing markup gains reusable host behavior. Prefer a direct template binding when the change is local and has no reusable behavior.

Prefer host bindings for normal properties, attributes, classes, styles, and events. Direct nativeElement mutation couples code to a browser DOM and complicates rendering, testing, and security, so reserve focused native access or Renderer2 for genuinely imperative work. hostDirectives members stay private by default; explicitly expose or alias only the inputs and outputs consumers need.

Browse Free Tutorials

Explore 500+ free tutorials across 20+ languages and frameworks.