Skip to content

Template syntax

Assignment

Martina Hostettler wants to see the catalog in the new app, the way it looked in the version built by hand.

As a member, I want to see every thing in the catalog with its category and availability, so that I know what I can borrow.

The acceptance criteria are:

  1. The catalog shows every thing with its name and category.
  2. Things that are in are marked "Available", things on loan show the date from which they are back, and they look different.
  3. A click on the name shows the deposit of the thing.

Learning goals

After this chapter you can

  • show values with interpolation and bind attributes with v-bind,
  • set classes and styles depending on data,
  • show parts of the page conditionally with v-if and v-show,
  • render lists with v-for and key,
  • react to events with v-on and use modifiers.

The template of a component is HTML with a few additions. The simplest one is interpolation with double curly braces. Vue evaluates the expression inside and inserts the result as text. Read the example and work out what each paragraph shows.

App.vue
<script setup>
const item = { name: 'Raclette oven', category: 'Kitchen', deposit: 30, availableFrom: null }
const note = '<b>Members only</b>'
</script>
<template>
<h1>{{ item.name }}</h1>
<!-- Every JavaScript expression works, a statement such as if does not -->
<p>{{ item.category.toUpperCase() }}, deposit CHF {{ item.deposit.toFixed(2) }}</p>
<p>{{ item.availableFrom === null ? 'Available' : 'On loan' }}</p>
<!-- Interpolation inserts text, so the tags appear as characters -->
<p>{{ note }}</p>
</template>

Between the braces goes a JavaScript expression, something that produces a value. Calls like toUpperCase(), arithmetic and the ? : operator are fine, statements like if, loops or const are not. For anything longer there are functions in the script and the computed values from the next chapter.

The last paragraph shows the tags of note as characters. Vue always inserts interpolation as text and so does what you needed escapeHtml for in part 2. The directive v-html, on the other hand, inserts HTML without checking it. It belongs only to content you wrote yourself, never to data from members or from an API.

Interpolation only works in text, not in attributes. You bind an attribute to an expression with the directive v-bind, usually in its short form with a colon. :href="'#/items/' + item.id" sets the attribute href to the result of the expression. Directives are attributes that start with v- and tell Vue what to do with an element.

The parts of a directive The directive v-on:click.prevent="reserve" split into its parts. v-on is the name of the directive, click the argument, prevent a modifier and reserve the value, a JavaScript expression. Below, the shorthands, @ stands for v-on: and the colon for v-bind:. v-on directive : click argument . prevent modifier = "reserve" value, a JavaScript expression Shorthands @click.prevent="reserve" equals v-on:click.prevent="reserve" :href="link" equals v-bind:href="link"

For attributes that are simply on or off, such as disabled, Vue removes the attribute when the expression is false. :disabled="item.availableFrom !== null" therefore only disables the button for things on loan.

App.vue
<script setup>
const drill = { id: 1, name: 'Drill', deposit: 20, availableFrom: null }
const tent = { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }
</script>
<template>
<!-- The colon binds an attribute to an expression, :href is short for v-bind:href -->
<article class="card" :class="{ 'on-loan': drill.availableFrom !== null }">
<a :href="'#/items/' + drill.id">{{ drill.name }}</a>
<button type="button" :disabled="drill.availableFrom !== null">Reserve</button>
</article>
<article class="card" :class="{ 'on-loan': tent.availableFrom !== null }" :style="{ borderWidth: tent.deposit >= 50 ? '3px' : '1px' }">
<a :href="'#/items/' + tent.id">{{ tent.name }}</a>
<button type="button" :disabled="tent.availableFrom !== null">Reserve</button>
</article>
</template>
<style>
.card {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #c8c8c8;
border-radius: 8px;
}
.on-loan {
background: #f6e7e4;
}
</style>

:class has a notation of its own for classes. You pass an object whose keys are classes, and Vue sets every class whose value is truthy. :class="{ 'on-loan': drill.availableFrom !== null }" sets the class on-loan only for things on loan. A plain class attribute next to it stays, and Vue merges the two. Instead of an object, an array like :class="['card', size]" works too.

:style takes an object with CSS properties in camelCase, such as { borderWidth: '3px' }. Classes are usually the better choice, because the looks then stay in the CSS and can be changed in one place.

v-if renders an element only if the expression is true. With v-else-if and v-else right after it, you get a chain of which Vue shows exactly one part. That fits the loading states you know from part 2.

App.vue
<script setup>
// Try 'loading' and 'error' as well
const status = 'ready'
const itemCount = 60
const showRules = false
</script>
<template>
<p v-if="status === 'loading'">Loading the catalog …</p>
<p v-else-if="status === 'error'">The server cannot be reached.</p>
<p v-else>{{ itemCount }} things in the catalog</p>
<!-- v-show only hides the element with CSS, it stays in the page -->
<p v-show="showRules">A loan lasts at most seven days.</p>
</template>

v-show, by contrast, only hides an element with display: none, and it stays on the page. That pays off for parts that open and close often. v-if fits when a part rarely appears or an element should be missing entirely. To show or hide several elements together without adding an extra element to the page, put v-if on a <template>. Vue doesn't render the <template> element itself.

v-for renders one element for every entry of an array. v-for="item in items" provides the entry under the name item, and v-for="(item, index) in items" also its position.

App.vue
<script setup>
const items = [
{ id: 1, name: 'Drill', category: 'Tools', availableFrom: null },
{ id: 2, name: 'Tent for four', category: 'Camping', availableFrom: '2026-11-12' },
{ id: 3, name: 'Raclette oven', category: 'Kitchen', availableFrom: null },
{ id: 4, name: 'Beer bench set', category: 'Parties', availableFrom: '2026-11-03' },
]
</script>
<template>
<ul>
<!-- The key tells Vue which entry belongs to which thing -->
<li v-for="(item, index) in items" :key="item.id" :class="{ 'on-loan': item.availableFrom !== null }">
{{ index + 1 }}. {{ item.name }}, {{ item.category }}
</li>
</ul>
</template>
<style>
.on-loan {
color: #a3341f;
}
</style>

The :key attribute tells Vue which element belongs to which entry. When the list changes, through a filter or a new sort order for instance, Vue moves the existing elements by their key instead of refilling them in order. Without a key, an input field or open details can end up at the wrong thing. The key must be unique and stable. The id of a thing works, the position index doesn't, because it changes when filtering.

Never put v-if and v-for on the same element. Vue evaluates v-if first, and the loop variable doesn't exist yet at that point. For a filtered list there are computed values from the next chapter, or you put v-for on a surrounding <template>.

v-on reacts to events, usually in its short form with @. @click="reserve" calls the function reserve on a click. If there is an expression, Vue runs it, for example @click="count = 0" or @click="toggle(item.id)". The event itself is available as $event.

The examples need a value that can change. That's what ref is for, a value the template follows. In the script you read and write it through .value, in the template without it. The next chapter explains what's behind this.

App.vue
<script setup>
import { ref } from 'vue'
// A ref holds a value that the template follows, the next chapter explains it
const count = ref(0)
const message = ref('')
function reserve() {
count.value++
}
</script>
<template>
<button type="button" @click="reserve">Reserve</button>
<button type="button" @click="count = 0">Reset</button>
<p>Reservations: {{ count }}</p>
<!-- .prevent keeps the browser from following the link -->
<a href="#/rules" @click.prevent="message = 'The loan rules follow soon.'">Loan rules</a>
<!-- .enter reacts to the Enter key only -->
<p>
<input aria-label="Note" @keyup.enter="message = $event.target.value">
</p>
<p>{{ message }}</p>
</template>

Modifiers are attached to the directive with a dot and take care of common tasks. .prevent calls preventDefault(), so the link goes nowhere. .stop stops the event before it reaches surrounding elements, and .once reacts only to the first event. For keys, modifiers like .enter or .esc select the key, so @keyup.enter reacts only to the Enter key.

Work with the example on the list. Read the code and write down what you expect before you change anything.

  1. What does the list show if you set availableFrom of the raclette oven to '2026-11-20'?
  2. Add v-if="item.availableFrom === null" to the li element as well. What does the playground report, and why?
  3. Fix the problem from question 2 with a <template v-for="item in items" :key="item.id"> around the li element, which now carries only v-if.
  4. Change :key="item.id" to :key="index". What do you see, and why doesn't the difference show in this example?

The exercises build the catalog from the assignment. You find the project in the course repo in the folder exercises/03-vue/template-syntax/en. Copy the folder, install the packages and run the tests.

Terminal window
npm install
npm test

With npm run dev you see the catalog in the browser. At first all tests fail.

Exercise Getting started

In App.vue, show all things as a list <ul class="catalog">. Every li entry contains the name in an element with the class name and the category in an element with the class category. Give every entry its id as the key.

Solution

The list comes from <li v-for="item in items" :key="item.id">. Inside are <span class="name">{{ item.name }}</span> and <span class="category">{{ item.category }}</span>. The solution to the challenge later turns the name into a button, and the class name stays.

Exercise Going further

For every thing, show "Available" or "Available again from 11/12/2026" in an element with the class state. formatDate from src/format.js formats the date. Give the entries of things on loan the class on-loan, and the CSS then turns their state red.

Solution

The class sits on the li element.

App.vue
<li v-for="item in items" :key="item.id" :class="{ 'on-loan': item.availableFrom !== null }">

For the state, two elements with v-if and v-else follow each other directly.

App.vue
<span v-if="item.availableFrom === null" class="state">Available</span>
<span v-else class="state">Available again from {{ formatDate(item.availableFrom) }}</span>

To make formatDate available in the template, you import it in the script. Everything that <script setup> imports can be used in the template.

Exercise Challenge

Turn the name into a button with the class name. A click on it shows the deposit in an element with the class details, such as "Deposit CHF 80.00", and a second click hides it again. At most one thing is open at any time. The details sit in every entry with v-show, and aria-expanded on the button tells screen readers whether they are open. const openId = ref(null) holds the state, and formatDeposit formats the deposit.

Solution
App.vue
<script setup>
import { ref } from 'vue'
import items from '../data/items.json'
import { formatDate, formatDeposit } from './format.js'
// The id of the thing whose details are open, null while all are closed
const openId = ref(null)
function toggle(id) {
openId.value = openId.value === id ? null : id
}
</script>
<template>
<h1>Catalog</h1>
<p>{{ items.length }} things</p>
<ul class="catalog">
<li v-for="item in items" :key="item.id" :class="{ 'on-loan': item.availableFrom !== null }">
<button type="button" class="name" :aria-expanded="openId === item.id" @click="toggle(item.id)">
{{ item.name }}
</button>
<span class="category">{{ item.category }}</span>
<span v-if="item.availableFrom === null" class="state">Available</span>
<span v-else class="state">Available again from {{ formatDate(item.availableFrom) }}</span>
<p v-show="openId === item.id" class="details">Deposit {{ formatDeposit(item.deposit) }}</p>
</li>
</ul>
</template>

openId remembers the id of the open thing. Since there is only one such value, only one thing can be open at a time. toggle closes the details when someone clicks the same name a second time. :aria-expanded gets true or false, and Vue writes the value into the attribute as text. A button instead of plain text can also be reached and triggered with the keyboard.

MistakeWhat helps
href="{{ link }}" leads to an address with curly braces.Attributes have no interpolation. Write :href="link".
disabled="false" still disables the button.Without the colon the value is text, and the attribute is set. Write :disabled="false".
Vue reports v-else/v-else-if has no adjacent v-if or v-else-if.v-else must follow the element with v-if directly, with no other element in between.
After filtering, input or open details end up at the wrong thing.Use a stable id as the key, not the position.
v-if and v-for on the same element, and the loop variable is missing.Put v-for on a surrounding <template> or filter the list beforehand.
An AI assistant shows text from the data with v-html.Use interpolation. v-html inserts HTML unchecked and opens the door to injected code.

Self-check

  1. How does {{ note }} differ from v-html="note"?
  2. What happens with disabled="false", and what with :disabled="false"?
  3. When do you choose v-show over v-if?
  4. Why does v-for need a key, and which value is suitable?
Answers
  1. Interpolation inserts the value as text, and tags appear as characters. v-html inserts it as HTML without checking it.
  2. Without the colon, "false" is text, the attribute is set and the button is disabled. With the colon, the expression is false, and Vue removes the attribute.
  3. When a part opens and closes often. v-show only hides the element, v-if removes it and builds it again.
  4. So that Vue matches the elements to the right entries when the list changes. A unique, stable value such as the id is suitable.

Assignment

The catalog is up, and the first volunteers at the counter are already looking at it. With 60 things, though, the list gets long. "Can you search it and filter by category?" asks Martina Hostettler. "And at the counter we'd like to see how many things are in right now." The next chapter makes the catalog reactive.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.