Template Syntax & Directives
Interpolation, v-bind, v-if, v-for and class bindings.
Vue templates are HTML with a few extra powers: text interpolation with {{ }}, and directives, special attributes starting with v- that bind data to the DOM. Once you know about eight directives you can build most interfaces.
This lesson covers interpolation, attribute binding, conditional rendering, list rendering and class/style bindings, each with a live demo built around the Task board.
Text interpolation
Double curly braces ("mustaches") render a JavaScript expression as text:
<script setup>
import { ref } from 'vue';
const user = ref({ name: 'Matt', tasks: 4 });
</script>
<template>
<p>Hello {{ user.name }}, you have {{ user.tasks }} tasks.</p>
<p>{{ user.tasks > 3 ? 'Busy day!' : 'Plenty of time.' }}</p>
<p>{{ user.name.toUpperCase() }}</p>
</template>
The demo runs exactly this template. Edit the name and the task count. Then try typing <b>bold?</b> as the name and notice it's shown as text, not rendered:
Rules for what goes inside {{ }}:
- One expression only.
{{ a + b }},{{ ok ? 'yes' : 'no' }}and{{ fn(x) }}are fine. Statements like{{ let x = 1 }}or{{ if (ok) {} }}are not. - The output is HTML-escaped. If
user.nameis<script>alert(1)</script>, it renders as harmless text. This is Vue's main XSS protection. - Expressions can reach the component's state plus a whitelist of globals (
Math,Date,JSONand so on), but not arbitrarywindowproperties.
v-html is the escape hatch. <div v-html="post.body"></div> renders raw HTML. Only use it with content you trust or have sanitised on the server. It's the Vue equivalent of Blade's {!! !!}. Never feed it user input.
Blade and Vue both use {{ }}
In a .vue file there's no conflict. But if you write Vue template markup inside a Blade file, Blade will try to evaluate {{ count }} as PHP first. Prefix the expression with @ so Blade leaves it alone, or wrap a block in @verbatim:
<!-- resources/views/tasks.blade.php (illustrative) -->
<div id="app">
<p>@{{ count }} tasks</p> <!-- Blade outputs {{ count }} for Vue -->
@verbatim
<p>{{ user.name }}</p> <!-- whole block ignored by Blade -->
@endverbatim
</div>
Directives at a glance
| Directive | Shorthand | What it does |
|---|---|---|
v-bind:attr |
:attr |
Bind an attribute or prop to an expression |
v-on:event |
@event |
Listen to an event |
v-model |
Two-way binding for form inputs and components | |
v-if / v-else-if / v-else |
Conditionally render an element | |
v-show |
Toggle CSS display |
|
v-for |
Render a list | |
v-html / v-text |
Set innerHTML / textContent |
|
v-slot |
#name |
Named and scoped slots |
v-once / v-memo |
Render once / memoise a subtree |
Events and v-model get their own lesson in Events & Forms. This page covers the rest.
Attribute binding with v-bind
Mustaches only work in text content, not in attributes. To make an attribute dynamic, bind it:
The examples on this page use computed(() => ...) for values derived from other state. It re-runs automatically when the state it reads changes. You'll explore it fully in Computed, Watchers & Lifecycle, later in this level.
<script setup>
import { ref, computed } from 'vue';
const label = ref('Save task');
const disabled = ref(false);
const tooltip = ref('Saves to the database');
const colour = ref('#42b883');
// Spread onto the button with v-bind. An empty tooltip becomes null, so the attribute is removed.
const attrs = computed(() => ({
disabled: disabled.value,
title: tooltip.value || null,
}));
</script>
<template>
<input v-model="label">
<input v-model="tooltip">
<input type="color" v-model="colour">
<label><input type="checkbox" v-model="disabled"> disabled</label>
<button v-bind="attrs" :style="{ background: colour }">{{ label }}</button>
</template>
Template literals work inside bindings too (illustrative):
<img :src="avatarUrl" :alt="`${user.name}'s avatar`">
<a :href="`/tasks/${task.id}`">{{ task.title }}</a>
Some useful behaviours:
- Boolean attributes:
:disabled="false"removes the attribute entirely.true(or'') adds it. nullorundefinedremoves any attribute::title="tooltip || null".- Same-name shorthand (Vue 3.4+):
:id="id"can be written as:id. - Bind many at once:
v-bind="{ id: 'task-1', class: 'card' }"spreads an object as attributes. This is handy for forwarding props. - Dynamic argument:
:[attrName]="value"picks the attribute name at runtime.
Here is that component. Change the inputs and watch the generated markup underneath:
Conditional rendering: v-if vs v-show
<template>
<p v-if="tasks.length === 0">🎉 Nothing to do.</p>
<p v-else-if="tasks.length < 5">A manageable {{ tasks.length }} tasks.</p>
<p v-else>{{ tasks.length }} tasks. Time to prioritise.</p>
<div v-show="showHelp" class="help">Press <kbd>N</kbd> to add a task.</div>
</template>
Add and remove tasks to move through the three branches, and toggle the help box:
Both hide things, but they work very differently:
v-if |
v-show |
|
|---|---|---|
| Mechanism | Adds or removes the element from the DOM | Toggles style="display: none" |
| Child components | Created and destroyed (lifecycle hooks run, state resets) | Created once and kept alive |
| Initial cost | Cheap if false (nothing rendered) | Always renders |
| Toggle cost | Higher (DOM creation/destruction) | Very cheap |
Works with v-else / <template> |
✅ | ❌ |
| Use when | Condition rarely changes, or content is expensive | Toggling often (tabs, dropdowns, tooltips) |
The demo below makes the difference visible. The same PanelCounter component, which holds a local count ref, is rendered once with each directive:
<PanelCounter v-if="ifOn" name="v-if" />
<PanelCounter v-show="showOn" name="v-show" />
Increment both, toggle them off and on, and see which one keeps its state:
v-if vs v-show. v-if is lazy, real conditional rendering: child components are destroyed and recreated, so local state resets and mount/unmount hooks fire. v-show only toggles CSS, so it costs more up front and less per toggle. Use v-show for things toggled frequently, and v-if for things that are rarely shown or expensive to render.
To conditionally render several elements without a wrapper <div>, put v-if on an invisible <template> tag:
<!-- Illustrative -->
<template v-if="task.done">
<span class="tick">✔</span>
<small>Completed {{ task.completedAt }}</small>
</template>
List rendering with v-for
<script setup>
import { ref } from 'vue';
const tasks = ref([
{ id: 1, title: 'Plan sprint', priority: 'high', done: false },
{ id: 2, title: 'Review PR', priority: 'normal', done: true },
{ id: 3, title: 'Update docs', priority: 'low', done: false },
]);
</script>
<template>
<ul>
<li v-for="(task, index) in tasks" :key="task.id">
{{ index + 1 }}. {{ task.title }}
</li>
</ul>
<!-- Objects: (value, key, index) -->
<dl>
<template v-for="(value, key) in tasks[0]" :key="key">
<dt>{{ key }}</dt><dd>{{ value }}</dd>
</template>
</dl>
<!-- Ranges: 1..5 -->
<span v-for="n in 5" :key="n">⭐</span>
</template>
Rendered live (the demo also includes the openTasks list from the next section):
Why :key matters
Vue reuses DOM elements when a list changes. Without a key it patches elements in place by position. That's fast, but it goes wrong when list items hold their own state: input values, focus, or child component state.
A :key gives each item a stable identity, so Vue can move elements instead of rewriting them. In the demo below, type something into each row's note field, then shuffle or remove the first item. Compare the left list (keyed by index) with the right one (keyed by task.id):
<!-- ❌ left -->
<div v-for="(task, index) in tasks" :key="index">
<span>{{ task.title }}</span> <input placeholder="note…">
</div>
<!-- ✅ right -->
<div v-for="task in tasks" :key="task.id">
<span>{{ task.title }}</span> <input placeholder="note…">
</div>
Don't use the array index as a key for lists that can be reordered, filtered or have items removed from the middle. It's equivalent to having no key. Use a stable unique ID, typically the database primary key.
Don't combine v-if and v-for on the same element
When both are on the same element, v-if has higher priority in Vue 3, so it can't see the v-for variable. Filter with a computed property instead. The "Open tasks" list in the v-for demo above is rendered this way:
<script setup>
import { computed } from 'vue';
const openTasks = computed(() => tasks.value.filter(t => !t.done));
</script>
<template>
<!-- ❌ <li v-for="task in tasks" v-if="!task.done"> -->
<li v-for="task in openTasks" :key="task.id">{{ task.title }}</li>
</template>
Class and style bindings
class and style accept objects and arrays as well as strings. Vue merges them with any static class:
- Object syntax:
:class="{ 'task--done': task.done }"applies the class when the value is truthy. - Array syntax:
:class="['badge', `badge--${task.priority}`]"lists classes, and can mix in objects. - Style objects:
:style="{ width: progress + '%' }"takes camelCase or'kebab-case'keys, with vendor prefixes added automatically.
Here is a task card where every visual change comes from those bindings. Complex class logic lives in a computed (cardClasses) to keep the template readable:
<script setup>
import { ref, computed } from 'vue';
const task = ref({ title: 'Ship the Task board', done: false, priority: 'high', progress: 60 });
const priorityColours = { low: '#64748b', normal: '#3b82f6', high: '#ef4444' };
const barColour = computed(() =>
task.value.progress >= 100 ? '#22c55e' : priorityColours[task.value.priority]
);
const cardClasses = computed(() => ({
'task--done': task.value.done,
[`task--${task.value.priority}`]: true,
}));
</script>
<template>
<div class="task" :class="cardClasses">
<div class="task__head">
<strong>{{ task.title }}</strong>
<span :class="['badge', `badge--${task.priority}`]">{{ task.priority }}</span>
</div>
<div class="bar">
<div :style="{ width: task.progress + '%', backgroundColor: barColour }"></div>
</div>
</div>
</template>
<style scoped>
.task { border: 1px solid #e5e7eb; border-left: 5px solid #3b82f6; border-radius: 10px; padding: 1rem; }
.task--low { border-left-color: #64748b; }
.task--high { border-left-color: #ef4444; }
.task--done { opacity: .55; background: #f8fafc; }
.task--done strong { text-decoration: line-through; }
.badge--high { color: #ef4444; }
/* … */
</style>
Key takeaways
{{ }}renders escaped text from a single expression. Usev-htmlonly for trusted HTML. In Blade files, write@{{ }}or use@verbatim.:attr(v-bind) makes attributes dynamic.null/falseremoves them, andv-bind="obj"spreads many.v-ifreally adds and removes elements and components.v-showtoggles CSS. Choose based on how often the condition changes.v-foralways needs a stable, unique:key. Never use the index for mutable lists, and filter withcomputedrather than combiningv-ifwithv-for.:classand:styleaccept objects and arrays, which makes state-driven styling declarative.