Template syntax

Interpolation

Any JS expression between {{ }}:

<span>{{ user.name }}</span>
<span>{{ price * quantity }} €</span>

Expressions may span several lines — both here and in every directive (:if, :each, :attrs, @event, …):

<span>{{ items
  .filter(item => item.active)
  .length }}</span>

Whitespace

A template is HTML, and its whitespace is HTML's: it reaches the DOM as written, and CSS decides what it's worth. Two elements on separate lines are separated by a space when they render inline — the same space you'd get from the same markup in an .html file — and by nothing when they're block or flex children. If you don't want the space, close the tags against each other (</span><span>) as you would anywhere else.

The one exception is the indentation between the branches of an :if/:elseif/:else chain, which is dropped: only one branch is ever in the DOM, so there's nothing for it to be a space between.

:attrs — dynamic attributes

Evaluates to an object; each entry becomes an attribute. null, undefined and false values remove the attribute.

<button :attrs="{ disabled: isSaving, title: tooltip }">Save</button>

:class — reactive classes

Adds classes on top of the static class attribute — it never replaces it. The expression may be a string, an object, or an array:

<button class="btn" :class="{ 'btn-active': active }">go</button>
<div :class="theme"></div>
<div :class="[theme, { active }]"></div>

Only classes the binding added are ever removed: the static list survives every re-run, even when the expression names one of its classes and then drops it (class="btn" :class="{ btn: cond }" keeps btn when cond goes false).

Don't combine it with a class key inside :attrs on the same element — :attrs rewrites the whole attribute on each of its runs, wiping whatever :class added. On a nested-component tag :class is ignored, like :text/:html.

:value / :checked / :selected — form state

These write the DOM property, not the attribute. The difference matters on form controls: the attribute is only the control's default, and detaches the moment the user interacts — :attrs="{ value }" stops driving an input once something has been typed into it. The property directives keep driving it:

<input :value="name" @input="name = $event.target.value">
<input type="checkbox" :checked="agreed" @change="agreed = $event.target.checked">
<select :value="lang">
  <option value="en">en</option>
  <option value="es">es</option>
</select>

:text / :html — content

Set an element's content directly from an expression, instead of interpolating inside its children.

<span :text="user.name"></span>
<div :html="markdownToHtml(post.body)"></div>

:html.allowed — destination policy

The sanitizer always blocks executable URLs (javascript:, data:), but by default it doesn't care where a link or image points. :html.allowed adds that restriction, per element — different zones of one page can trust different destinations, which is the one thing a page-wide Content-Security-Policy can't express:

<div :html="body" :html.allowed="'*.germade.dev'"></div>
<div :html="body" :html.allowed="['*.germade.dev', '*.germade.es']"></div>
<div :html="body" :html.allowed="url => url.hostname.endsWith('.germade.dev')"></div>

The value is an expression, like every : attribute: a comma-separated string or array of host patterns, or a predicate (url: URL, tag, attr) => boolean called with the URL already resolved against the page (so relative URLs are judged as the same-origin destinations they are). A rejected href/src is stripped; the element and its text stay.

Pattern grammar — host[:port]:

Without .allowed, :html keeps its default: protocol check only, any destination. For a page-wide floor, set a Content-Security-Policy — the two compose, and the stricter one wins.

:if / :elseif / :else — conditionals

Consecutive siblings form one chain; only the active branch is in the DOM.

<div :if="score > 8">great</div>
<div :elseif="score > 4">ok</div>
<div :else>bad</div>

:each / :key — lists

<li :each="user in users" :key="user.id">{{ $index }}: {{ user.name }}</li>

The list is diffed by key: unchanged items keep their DOM (and state) when the array is reordered, filtered or extended. Without :key, position is used — fine for append-only lists, wasteful for reordering. $index is available inside each item.

A second binding names the array index — handy where nested loops would shadow $index — and plain objects iterate as their entries, the second binding being the property key (parens optional):

<li :each="item, i in items">{{ i }}: {{ item.name }}</li>
<li :each="(value, key) in labels">{{ key }} = {{ value }}</li>

Objects diff by property key out of the box: adding, changing or deleting a key touches only that entry. Anything that is neither an array nor a plain object renders nothing.

:with — narrowed scope

Evaluates to an object whose properties become directly addressable inside the element; anything else still resolves from the outer scope:

<div :each="item in items">
  <div>{{ item.name }}</div>
  <div :with="item">
    Another way to get: {{ name }}
    Items total: {{ items.length }}
  </div>
</div>

@event — listeners

<button @click="onClick"></button>
<form @submit.prevent="$event => onSubmit($event)"></form>
<button @click="count = count + 1">clicked {{ count }} times</button>

The attribute value is evaluated on every event with $event in scope; if it evaluates to a function, that function is called with the event. So all three styles work: a handler reference, an inline arrow, or an inline statement that mutates reactive data.

Modifiers (chainable, e.g. @click.stop.once):

modifier effect
.prevent event.preventDefault()
.stop event.stopPropagation()
.self only fire when event.target is the element itself
.once listener runs at most once
.capture listen in the capture phase

@event on a component tag

A component tag renders as comment anchors — there is no element to listen on — so @event there subscribes to that child's $emit channel instead:

<Stepper @changed="last = $event.detail" />

It hears exactly what that child emits: not a grandchild's emits (those arrive only as an explicit re-emit), and not native DOM events from the child's inner DOM — a native submit bubbles past the tag's anchors to real ancestors, so it's still a wrapping element's to catch (<div @submit.prevent=…><LoginForm/></div>, which hears $emits too, since they bubble). Modifiers on this channel: .prevent flips the child's $emit(...) return to false — a "the parent vetoed" signal; .stop keeps the emit off the DOM entirely, so wrapping elements never hear it; .once unsubscribes after one call; .self and .capture have no meaning here and are ignored.

:model — two-way component binding

Props flow down, events flow up; :model wires both at once. The model's name rides the modifier (an expression-valued modifier, like :html.allowed), and one tag can carry several:

<LoginForm :model.uname="uname" :model.password="password" />
<EmailField :model="email" />

Each :model[.name]="expr" is two bindings:

The child's whole side is one emit, straight from the template if it's simple enough:

<!-- EmailField.html -->
<input :value="model" @input="$emit('model:update', { value: $event.target.value })">

Nested components

A tag matching a PascalCase scope variable renders as a child component. Components reach the scope through render data, :setup props, or an await import(...) in the setup script:

<script :setup="{ user, NestedComponent }">
  const ImportedComponent = await import('/components/foobar.html')
</script>

<div>
  <NestedComponent :user :title="'Hardcoded title'" />
  <ImportedComponent :user="user" />
</div>
<!-- /components/foobar.html -->
<script :setup="{ user }"></script>
<div>User: {{ user.firstName }}</div>