An Angular template is HTML with four extra pieces of syntax bolted on. Learn what those four are and there is very little left to learn — everything else in a template is either ordinary HTML or the control flow from the next lesson.
The four are worth memorising as a set, because the punctuation tells you which one you are looking at:
{{ value }} interpolation — put a value into the text
[property]="expr" property binding — set something ON the element
(event)="handler()" event binding — react to something the element does
[(thing)]="expr" two-way — both of the above at onceSquare brackets mean data flowing in. Round brackets mean events flowing out. The two-way form is literally both symbols together, which is why it is nicknamed the banana in a box.
Interpolation
{{ }} evaluates an expression and puts the result into the page as text. From the
product card:
<h3 class="card-title h6 fw-bold mb-1">{{ product().name }}</h3>
<p class="card-text text-muted small flex-grow-1">{{ product().description }}</p>Note the parentheses. product is a signal, so product() reads it —
and reading a signal inside a template is what subscribes the template to it. Forget the
parentheses and you interpolate the signal function itself, which renders something like
function computed() and is one of the more confusing first-week bugs.
The expression can do a little work. A ternary and a pipe are both fine:
<div class="product-thumb" aria-hidden="true">{{ isPizza() ? '🍕' : '🥤' }}</div>
...
<span class="fw-bold">
from <span class="text-pizza-red">{{ cheapest() | money }}</span>
</span>Whatever the expression returns is converted to a string. null and
undefined render as nothing at all, rather than as the words — which is usually what you
want and occasionally hides a bug.
Property binding
[thing]="expr" sets a property on the element or component. This is how data
reaches a child component:
<app-cart-drawer [open]="cartOpen()" (closed)="cartOpen.set(false)" />open is an input() on CartDrawer; closed
is an output(). Data in, events out, in one line — that is the shape of nearly every
parent/child relationship in Angular, and it has a lesson of its own shortly.
Why the brackets matter
These two are not the same thing:
<img src="imageUrl" /> <!-- the literal string "imageUrl" -->
<img [src]="imageUrl" /> <!-- the VALUE of imageUrl -->Without brackets the right-hand side is a string, exactly as in plain HTML. With brackets it is an expression that Angular evaluates. This is the single most common early mistake, and it fails quietly: the image just does not load.
Attribute binding
Property binding sets a DOM property. Most HTML attributes have a matching
property, so [disabled], [value] and [src] all work. Some have
no property at all — everything aria-*, everything on an SVG element, and
colspan among others. For those, say so explicitly:
<button
type="button"
class="btn btn-sm btn-outline-danger"
[disabled]="self"
[attr.title]="self ? 'You cannot delete your own account' : null"
(click)="remove(row)"
>
Delete
</button>[disabled] is a property; [attr.title] is an attribute. And the
null in that ternary is doing real work: binding an attribute to
null removes it, rather than setting it to the string "null". That is the
idiomatic way to have an attribute present only sometimes.
Class and style bindings
A special case of property binding, and worth knowing because the alternative — building a class string by hand — is miserable.
<div
class="modal-dialog modal-dialog-centered"
[class.modal-lg]="size() === 'lg'"
[class.modal-dialog-scrollable]="scrollable()"
>[class.x]="condition" adds the class x when the condition is truthy
and removes it when it is not. The static class attribute alongside it is left alone —
the two combine rather than fight, so the shared classes stay declarative and only the conditional
ones need an expression.
Style bindings work the same way and add something genuinely useful — a unit suffix:
<div
style="height: 10px; border-radius: 4px; background: var(--viz-series-1)"
[style.width.%]="row.width"
></div>[style.width.%]="row.width" takes the number 42 and writes
width: 42%. The chart components use [style.left.px] the same way. It saves
the string concatenation that would otherwise appear at every one of these.
Two-way binding
[(x)] is sugar for a property binding and an event binding together:
<input [(ngModel)]="email" />
<!-- exactly the same thing, written out -->
<input [ngModel]="email" (ngModelChange)="email = $event" />It is still one-way data flow underneath, which is the point worth holding on to. Angular is
not doing anything magic; it is a naming convention — a property x plus an output called
xChange — and you can put it on your own components.
The pizza app's login form deliberately writes the long form,
[ngModel] and (ngModelChange) separately, because its fields are signals
and email.set($event) is not an assignment target. That trade-off belongs to the forms
lesson.
Template reference variables
#name on an element gives you a handle to it, usable elsewhere in the same
template — or in the class:
<button
#closeButton
type="button"
class="btn-close"
aria-label="Close"
(click)="closed.emit()"
></button>private readonly closeButton = viewChild<ElementRef<HTMLButtonElement>>('closeButton');That is how the modal moves focus to its close button when it opens. It is Angular's
useRef, with the difference that the reference is declared in the markup and picked up by
the class, rather than created in the class and passed down.
@let, for when you need a local
A template is not JavaScript, so there is no const. When the same value is needed
more than once, @let binds it for the rest of the block:
@let selected = isSelected(topping.id);
<button
type="button"
class="btn btn-sm topping-chip"
[class.btn-primary]="selected"
[class.btn-outline-secondary]="!selected"
[attr.aria-pressed]="selected"
(click)="toggleTopping(topping.id)"
>
{{ topping.name }}
<span class="ms-1 small opacity-75">+{{ topping.price | money }}</span>
</button>Without it, isSelected(topping.id) would be called three times for every topping
chip. @let is read-only and scoped to the block it is declared in — it cannot be
reassigned, which is deliberate.
What you cannot write in a template
Template expressions are a deliberately restricted subset of JavaScript. No
new, no ++ or --, no bitwise operators, no chained statements
with ;, and no assignment outside an event binding. Global objects are not in scope
either: window, document, console and Math are all
unavailable.
That is not Angular being awkward. A template is re-evaluated whenever change detection runs, so anything in it must be cheap and free of side effects. The restrictions make the whole class of "why did this run four times" bug impossible to write.
The practical consequence: calculations belong in the class, as a
computed(). The product card does not compute its lowest price in the template —
readonly isPizza = computed(() => this.product().type === 'PIZZA');
readonly cheapest = computed(() => Math.min(...this.product().sizes.map((s) => s.price)));— and the template just reads cheapest(). Both of those need
Math, which a template cannot reach anyway. The restriction and the good habit point the
same direction.
Templates are type checked
Worth knowing early: the expressions above are compiled and type checked against your
component class. Misspell product().nmae and the build fails, naming the file and the
line. A template is not a string that fails at runtime — it is code, and the compiler treats it as
code.
What is next
Event bindings got one line here and deserve more: $event, the key filters like
(keydown.escape) used by the modal above, and where handler logic should actually
live.