Templates
An Angular template is HTML enhanced with Angular-specific syntax. When Angular compiles your app (Ahead-of-Time), it transforms these templates into highly optimized JavaScript. The result: zero runtime template parsing, fast rendering, and full TypeScript type checking on your HTML.
Angular templates support the following special syntax on top of standard HTML:
Interpolation: {{ expression }} — render a value as text
Property binding: [property]="expression" — bind to a DOM or component property
Event binding: (event)="handler()" — listen to DOM events
Two-way binding: [(ngModel)]="value" — sync model and view
Control flow: @if, @for, @switch, @defer (Angular 17+)
Template variables: #refName — reference to an element or component
Pipes: value | pipeName — transform displayed values
Safe navigation: obj?.prop — prevent null reference errors
Interpolation
Double curly braces {{ }} evaluate a TypeScript expression and render it as a string. You can use any valid TypeScript expression that produces a value:
<!-- Simple property -->
<h1>{{ title }}</h1>
<!-- Method call -->
<p>{{ getFullName() }}</p>
<!-- Arithmetic expression -->
<p>Total: {{ price * quantity }}</p>
<!-- Ternary -->
<p>{{ isLoggedIn ? 'Welcome back!' : 'Please sign in' }}</p>
<!-- String method -->
<p>{{ name.toUpperCase() }}</p>
<!-- Safe navigation for potentially null values -->
<p>{{ user?.profile?.bio }}</p>
<!-- Pipe transform -->
<p>{{ birthday | date:'longDate' }}</p>Property Binding
Square brackets [property] bind an expression to a DOM property (not an HTML attribute). This is different from interpolation — property binding sets the property's value directly:
<!-- DOM property binding --> <img [src]="imageUrl" [alt]="imageDescription" /> <button [disabled]="isLoading">Submit</button> <input [value]="searchTerm" [placeholder]="placeholderText" /> <!-- Component @Input binding --> <app-user-card [user]="currentUser" [isHighlighted]="true" /> <!-- Class binding --> <div [class]="dynamicClass">...</div> <div [class.active]="isActive">...</div> <!-- single class toggle --> <div [class.loading]="isLoading" [class.error]="hasError">...</div> <!-- Style binding --> <div [style.color]="textColor">...</div> <div [style.font-size.px]="fontSize">...</div> <!-- with unit --> <div [style]="styleObject">...</div> <!-- object with multiple styles --> <!-- Attribute binding (for ARIA and non-standard attributes) --> <div [attr.aria-label]="buttonLabel">...</div> <td [attr.colspan]="colSpan">...</td>
Property vs Attribute binding:
- Use
[property]for DOM properties (src,value,disabled) - Use
[attr.name]for HTML attributes that have no corresponding DOM property (colspan,aria-*)
Event Binding
Parentheses (event) listen to DOM events and call a component method when the event fires:
<!-- Click event --> <button (click)="onSave()">Save</button> <!-- Input event — fires on every keystroke --> <input (input)="onSearch($event)" /> <!-- Change event — fires when value is committed --> <select (change)="onCategoryChange($event)">...</select> <!-- Form submit --> <form (ngSubmit)="onSubmit()">...</form> <!-- Mouse events --> <div (mouseenter)="onHover(true)" (mouseleave)="onHover(false)">...</div> <!-- Keyboard events --> <input (keydown.enter)="onEnter()" (keydown.escape)="onEscape()" /> <!-- Passing data to the handler --> <button (click)="onDelete(item.id)">Delete</button> <app-card (selected)="onCardSelected($event)" />
// Component class handling events
export class SearchComponent {
query = '';
onSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.query = input.value;
}
onCategoryChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.loadCategory(select.value);
}
private loadCategory(category: string): void {
// fetch data...
}
}Two-Way Binding
Two-way binding [(ngModel)] synchronizes a form control's value with a component property in both directions — automatically. It requires FormsModule:
// Import FormsModule in the component
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-search',
standalone: true,
imports: [FormsModule],
template: `
<input [(ngModel)]="query" placeholder="Search..." />
<p>You typed: {{ query }}</p>
<button (click)="query = ''">Clear</button>
`,
})
export class SearchComponent {
query = '';
}[(ngModel)]="query" is shorthand for:
<!-- Two-way binding is shorthand for this: --> <input [value]="query" (input)="query = $event.target.value" />
Control Flow — @if
Angular 17 introduced built-in control flow that replaces structural directives (*ngIf, *ngFor). The new syntax uses @ prefixed blocks:
<!-- @if (replaces *ngIf) -->
@if (user) {
<p>Welcome, {{ user.name }}!</p>
} @else if (isLoading) {
<app-spinner />
} @else {
<p>Please sign in.</p>
}
<!-- Old syntax (still works, but @if is preferred) -->
<p *ngIf="user; else loadingTemplate">Welcome, {{ user.name }}!</p>
<ng-template #loadingTemplate><app-spinner /></ng-template>Control Flow — @for
<!-- @for (replaces *ngFor) — track is REQUIRED -->
@for (product of products; track product.id) {
<app-product-card [product]="product" />
} @empty {
<p>No products found.</p>
}
<!-- Accessing loop variables -->
@for (item of items; track item.id; let i = $index, last = $last) {
<li [class.last]="last">{{ i + 1 }}. {{ item.name }}</li>
}
<!-- Available loop context variables:
$index — current index (0-based)
$first — true if first item
$last — true if last item
$even — true if index is even
$odd — true if index is odd
$count — total number of items
-->track expression in @for is required (unlike trackBy in *ngFor). It must return a unique identifier for each item. This is how Angular efficiently updates the DOM when the list changes.Control Flow — @switch
@switch (user.role) {
@case ('admin') {
<app-admin-panel />
}
@case ('editor') {
<app-editor-tools />
}
@default {
<app-viewer-panel />
}
}@defer — Lazy Loading Template Blocks
@defer (Angular 17+) lazily loads a part of the template, reducing initial bundle size. The deferred block is only downloaded when the trigger condition is met:
<!-- Load the component only when it scrolls into view -->
@defer (on viewport) {
<app-heavy-chart [data]="chartData" />
} @placeholder {
<div class="chart-placeholder">Chart loading...</div>
} @loading (minimum 500ms) {
<app-spinner />
} @error {
<p>Failed to load chart.</p>
}
<!-- Defer triggers:
on idle — when browser is idle
on viewport — when element enters viewport
on interaction — on click or focus
on hover — on hover
on timer(2000) — after a delay
when condition — when an expression becomes truthy
-->Template Variables
Template variables (prefixed with #) give you a reference to an element or component in the template:
<!-- Reference a DOM element --> <input #emailInput type="email" /> <button (click)="submit(emailInput.value)">Submit</button> <!-- Reference a component instance --> <app-dialog #myDialog /> <button (click)="myDialog.open()">Open Dialog</button> <!-- Reference an NgForm (template-driven forms) --> <form #loginForm="ngForm" (ngSubmit)="onLogin(loginForm)"> <input name="email" ngModel required /> <button [disabled]="loginForm.invalid">Login</button> </form>
// Access template variables in the component via @ViewChild
import { ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({ ... })
export class LoginComponent implements AfterViewInit {
@ViewChild('emailInput') emailInputRef!: ElementRef<HTMLInputElement>;
ngAfterViewInit() {
this.emailInputRef.nativeElement.focus();
}
}Pipes in Templates
Pipes transform data for display without changing the underlying value:
<!-- Built-in pipes -->
<p>{{ price | currency:'USD':'symbol':'1.2-2' }}</p> <!-- $1,234.56 -->
<p>{{ birthday | date:'MMMM d, y' }}</p> <!-- July 1, 2024 -->
<p>{{ 0.75 | percent }}</p> <!-- 75% -->
<p>{{ message | uppercase }}</p> <!-- HELLO WORLD -->
<p>{{ title | titlecase }}</p> <!-- Hello World -->
<p>{{ longText | slice:0:100 }}...</p> <!-- first 100 chars -->
<p>{{ items | json }}</p> <!-- JSON string (debug) -->
<p>{{ data$ | async }}</p> <!-- unwrap Observable -->
<!-- Chaining pipes -->
<p>{{ name | uppercase | slice:0:5 }}</p>
<!-- Custom pipe -->
<p>{{ text | truncate:50 }}</p>ng-container — Grouping Without Adding DOM Nodes
<ng-container> is a logical grouping element that does not render to the DOM. Use it to apply control flow to multiple elements without a wrapper div:
<!-- Without ng-container — adds an unwanted div to the DOM -->
<div *ngIf="isLoggedIn">
<span>Welcome</span>
<a href="/profile">Profile</a>
</div>
<!-- With ng-container — no extra DOM element -->
<ng-container *ngIf="isLoggedIn">
<span>Welcome</span>
<a href="/profile">Profile</a>
</ng-container>
<!-- @if does not need ng-container (new control flow is block-based) -->
@if (isLoggedIn) {
<span>Welcome</span>
<a href="/profile">Profile</a>
}
<!-- ng-container for multiple structural directives on one element -->
<ng-container *ngFor="let item of items">
<ng-container *ngIf="item.isVisible">
<app-item [data]="item" />
</ng-container>
</ng-container>ng-template — Reusable Template Fragments
<!-- Define a reusable template fragment -->
<ng-template #loadingTpl>
<div class="loading">
<app-spinner />
<p>Loading...</p>
</div>
</ng-template>
<!-- Reference it elsewhere -->
@if (isLoading) {
<ng-container *ngTemplateOutlet="loadingTpl" />
} @else {
<app-content />
}
<!-- Pass context to a template -->
<ng-template #itemTpl let-item let-index="index">
<li>{{ index + 1 }}. {{ item.name }}</li>
</ng-template>Template Syntax Comparison: Old vs New
Old syntax | New syntax (Angular 17+) |
|---|---|
*ngIf="condition" | @if (condition) { } |
*ngIf="c; else t" | @if (c) { } @else { } (using ng-template #t) |
*ngFor="let x of xs; trackBy: fn" | @for (x of xs; track x.id) { } |
*ngFor="let x of xs; let i = index" | @for (x of xs; track x.id; let i = $index) { } |
[ngSwitch]="val" + *ngSwitchCase | @switch (val) { @case (v) { } } |
No defer block | @defer (on viewport) { } |
@if / @for syntax for all new code. You can run ng generate @angular/core:control-flow to automatically migrate an existing project.