Custom Structural Directives
Structural directives change the shape of the DOM by adding, removing, or repeating elements — *ngIf and *ngFor are the canonical examples. When the built-in directives and the new @if/@for control flow do not cover a niche rendering rule, you can author your own structural directive. The two building blocks are TemplateRef, a handle to the template you want to stamp out, and ViewContainerRef, the location in the DOM where the rendered views are inserted or destroyed.
How the asterisk microsyntax works
The leading * is pure syntactic sugar. When Angular sees *appUnless="condition", it desugars the host element into an <ng-template> and moves the directive onto it:
<!-- What you write -->
<p *appUnless="isLoggedIn">Please sign in.</p>
<!-- What Angular compiles it into -->
<ng-template appUnless="isLoggedIn">
<p>Please sign in.</p>
</ng-template>
Because the content lives inside an <ng-template>, it is not rendered automatically. The directive itself decides whether and when to create a view from that template. This is exactly what makes structural directives different from attribute directives — they control the existence of DOM, not just its appearance.
Building an *appUnless directive
A structural directive injects TemplateRef (the template to render) and ViewContainerRef (where to render it), then uses an input setter to react to value changes. The classic example renders content only when a condition is false.
import { Directive, Input, TemplateRef, ViewContainerRef, inject } from '@angular/core';
@Directive({
selector: '[appUnless]',
standalone: true,
})
export class UnlessDirective {
private templateRef = inject(TemplateRef<unknown>);
private viewContainer = inject(ViewContainerRef);
private hasView = false;
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}
The hasView flag avoids recreating the view on every change-detection cycle. createEmbeddedView() stamps out the template; clear() destroys all views in the container. Using the directive is straightforward:
import { Component } from '@angular/core';
import { UnlessDirective } from './unless.directive';
@Component({
selector: 'app-root',
standalone: true,
imports: [UnlessDirective],
template: `
<button (click)="loading = !loading">Toggle</button>
<p *appUnless="loading">Content is ready.</p>
<p *appUnless="!loading">Loading...</p>
`,
})
export class AppComponent {
loading = false;
}
Output:
Content is ready.
After clicking the button, the first paragraph is destroyed and the second is created, displaying Loading....
Passing context to the template
Structural directives can expose local variables to their template through an embedded view context. *ngFor does this with index, even, and friends. The context object is the second argument to createEmbeddedView(), and template consumers read it with the let- syntax.
import { Directive, Input, TemplateRef, ViewContainerRef, inject } from '@angular/core';
interface RepeatContext {
$implicit: number;
count: number;
}
@Directive({
selector: '[appRepeat]',
standalone: true,
})
export class RepeatDirective {
private templateRef = inject(TemplateRef<RepeatContext>);
private viewContainer = inject(ViewContainerRef);
@Input() set appRepeat(times: number) {
this.viewContainer.clear();
for (let i = 0; i < times; i++) {
this.viewContainer.createEmbeddedView(this.templateRef, {
$implicit: i,
count: times,
});
}
}
}
<p *appRepeat="3; let i; let total = count">Row {{ i }} of {{ total }}</p>
Output:
Row 0 of 3
Row 1 of 3
Row 2 of 3
The $implicit property binds to the bare let i, while named properties (count) are bound with let total = count.
Microsyntax keys and multiple inputs
The desugaring also supports additional bound inputs separated by ;. For example *appRepeat="count; trackBy: fn" maps the second clause to an @Input() appRepeatTrackBy. Angular constructs each input name by concatenating the selector with the camel-cased key.
| Template clause | Directive input | Purpose |
|---|---|---|
*appUnless="x" | appUnless | Primary value (the part after *) |
*appRepeat="n; trackBy: f" | appRepeatTrackBy | Secondary keyed input |
let v | template context $implicit | Exposes the implicit context value |
let c = count | template context count | Exposes a named context property |
Tip: prefer the native
@if/@for/@switchcontrol flow for conditional and list rendering. Reserve custom structural directives for genuinely reusable rendering rules — permission gating, feature flags, lazy reveal — that the built-ins cannot express cleanly.
Type checking the template context
To get strict template type checking inside the directive’s <ng-template>, add a static ngTemplateContextGuard. Angular uses it to narrow the context type during compilation.
import { Directive, TemplateRef } from '@angular/core';
@Directive({ selector: '[appRepeat]', standalone: true })
export class RepeatDirective {
static ngTemplateContextGuard(
dir: RepeatDirective,
ctx: unknown,
): ctx is RepeatContext {
return true;
}
// ...inputs and rendering as above
}
With this guard, {{ i }} and {{ total }} are type-checked against RepeatContext when strictTemplates is enabled.
Best practices
- Inject
TemplateRefandViewContainerRefwithinject()— they are only available on directives applied via the*microsyntax. - Guard view creation with a flag (or
clear()first) so you do not stamp duplicate views on every change-detection pass. - Keep one structural directive per host element; Angular forbids two
*directives on the same element. - Expose data through a typed context object and add an
ngTemplateContextGuardfor safelet-bindings. - Always clean up by calling
viewContainer.clear()when the condition no longer holds; embedded views are destroyed with it. - Reach for native
@if/@forfirst, and write custom directives only for reusable, domain-specific rendering logic.