Skip to main content
Advertisement

17.4 Template Syntax

Svelte's template syntax is based on HTML, extended with special blocks and directives for dynamic content. All logic blocks follow the {#...}, {:...}, {/...} pattern.


Svelte Template Basics

<script>
let name = $state('Svelte');
let version = $state(5);
</script>

<!-- Expression interpolation -->
<h1>Hello, {name}!</h1>
<p>Version: {version}</p>

<!-- JavaScript expressions work -->
<p>{name.toUpperCase()} v{version * 1.0}</p>

<!-- Raw HTML rendering (use with caution!) -->
<p>{@html '<strong>Bold text</strong>'}</p>

{@html} can be vulnerable to XSS attacks. Only use it with trusted content.


\{#if} — Conditional Rendering

<script>
let score = $state(75);
let isLoggedIn = $state(false);
let userRole = $state('user');
</script>

<!-- Basic if/else if/else -->
{#if score >= 90}
<p>Excellent</p>
{:else if score >= 70}
<p>Pass</p>
{:else if score >= 50}
<p>Average</p>
{:else}
<p>Fail</p>
{/if}

<!-- Authentication example -->
{#if isLoggedIn}
{#if userRole === 'admin'}
<AdminPanel />
{:else}
<UserDashboard />
{/if}
{:else}
<LoginForm />
{/if}

\{#each} — Loop Rendering

Basic Usage

<script>
let fruits = $state(['apple', 'banana', 'cherry', 'grape']);

let users = $state([
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Carol', age: 28 },
]);
</script>

<!-- Basic each -->
<ul>
{#each fruits as fruit}
<li>{fruit}</li>
{/each}
</ul>

<!-- With index -->
<ol>
{#each fruits as fruit, index}
<li>{index + 1}. {fruit}</li>
{/each}
</ol>

<!-- Object array -->
{#each users as user}
<div>{user.name} (age {user.age})</div>
{/each}

<!-- Destructuring -->
{#each users as { id, name, age }}
<div key={id}>{name} - {age} years old</div>
{/each}

Keys for Efficient Rendering

Specifying a key allows Svelte to identify each item and update the DOM efficiently.

<script>
let todos = $state([
{ id: 1, text: 'Grocery shopping', done: false },
{ id: 2, text: 'Exercise', done: true },
{ id: 3, text: 'Reading', done: false },
]);

function addTodo() {
todos.unshift({ id: Date.now(), text: 'New task', done: false });
}

function removeTodo(id) {
todos = todos.filter(t => t.id !== id);
}
</script>

<!-- (todo.id) is the key -->
{#each todos as todo (todo.id)}
<div>
<input type="checkbox" bind:checked={todo.done} />
<span class:done={todo.done}>{todo.text}</span>
<button onclick={() => removeTodo(todo.id)}>Delete</button>
</div>
{:else}
<p>No todos found.</p>
{/each}

Without a key, Svelte uses index-based updates, which can cause unexpected behavior when adding or removing items.


\{#await} — Async Handling

<script>
async function fetchUser(id) {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
if (!res.ok) throw new Error('User not found.');
return res.json();
}

let userId = $state(1);
let userPromise = $derived(fetchUser(userId));
</script>

{#await userPromise}
<!-- Loading -->
<p>Loading...</p>
{:then user}
<!-- Success -->
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
</div>
{:catch error}
<!-- Error -->
<p class="error">Error: {error.message}</p>
{/await}

<button onclick={() => userId = userId % 10 + 1}>Next User</button>

Simplified await (Result Only)

<script>
let dataPromise = fetch('/api/data').then(r => r.json());
</script>

<!-- Handle only the result, skip loading state -->
{#await dataPromise then data}
<p>{data.message}</p>
{/await}

\{#key} — Force Re-render

Forces a component to completely recreate when the value changes.

<script>
import { fly } from 'svelte/transition';
let currentPage = $state(1);
</script>

<!-- Content is recreated fresh every time currentPage changes -->
{#key currentPage}
<div in:fly={{ x: 200 }}>
<h2>Page {currentPage}</h2>
<PageContent page={currentPage} />
</div>
{/key}

<button onclick={() => currentPage++}>Next Page</button>

{@const} — Local Constant Declarations

Declare computed values as constants for reuse within a block.

<script>
let products = $state([
{ name: 'Laptop', price: 1200, quantity: 2 },
{ name: 'Mouse', price: 35, quantity: 5 },
]);
</script>

{#each products as product}
{@const subtotal = product.price * product.quantity}
{@const formatted = subtotal.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
<div>
<span>{product.name}</span>
<span>{formatted} ({product.quantity} units)</span>
</div>
{/each}

Event Handling

Svelte 5 Event Syntax

Svelte 5 uses the same approach as standard HTML event attributes.

<script>
let count = $state(0);
let log = $state([]);

function handleClick() {
count++;
}

function handleInput(event) {
log.push(event.target.value);
}

function handleMouseMove(event) {
console.log(event.clientX, event.clientY);
}
</script>

<!-- Function reference -->
<button onclick={handleClick}>Click ({count})</button>

<!-- Inline handler -->
<button onclick={() => count++}>Inline (+1)</button>

<!-- With event object -->
<input oninput={handleInput} />
<div onmousemove={handleMouseMove}>Mouse area</div>

<!-- Form submit -->
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
<button type="submit">Submit</button>
</form>

Svelte 4 vs Svelte 5 Event Syntax

<!-- Svelte 4 (legacy, still works) -->
<button on:click={handler}>Click</button>
<input on:input={handler} />
<form on:submit|preventDefault={handler}>...</form>

<!-- Svelte 5 (recommended) -->
<button onclick={handler}>Click</button>
<input oninput={handler} />
<form onsubmit={(e) => { e.preventDefault(); handler(e); }}>...</form>

Bindings

bind:value — Two-Way Binding

<script>
let text = $state('');
let number = $state(0);
let isChecked = $state(false);
let selectedOption = $state('option1');
let selectedMultiple = $state([]);
</script>

<!-- Text input -->
<input bind:value={text} placeholder="Enter text" />
<p>Input: {text}</p>

<!-- Number (auto-converts to number type) -->
<input type="number" bind:value={number} />
<p>Double: {number * 2}</p>

<!-- Checkbox -->
<input type="checkbox" bind:checked={isChecked} />
<p>Checked: {isChecked}</p>

<!-- Select -->
<select bind:value={selectedOption}>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
</select>

<!-- Multiple select -->
<select multiple bind:value={selectedMultiple}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>

bind:this — DOM Element Reference

<script>
import { onMount } from 'svelte';

let canvas;
let inputEl;

onMount(() => {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ff3e00';
ctx.fillRect(10, 10, 100, 100);

inputEl.focus();
});
</script>

<canvas bind:this={canvas} width="200" height="200"></canvas>
<input bind:this={inputEl} placeholder="Auto-focused" />

Group Binding

<script>
let selectedColors = $state([]);
let selectedSize = $state('M');
</script>

<!-- Checkbox group -->
<fieldset>
<legend>Select Colors (multiple)</legend>
{#each ['Red', 'Green', 'Blue', 'Yellow'] as color}
<label>
<input type="checkbox" bind:group={selectedColors} value={color} />
{color}
</label>
{/each}
</fieldset>
<p>Selected: {selectedColors.join(', ') || 'none'}</p>

<!-- Radio group -->
<fieldset>
<legend>Select Size</legend>
{#each ['S', 'M', 'L', 'XL'] as size}
<label>
<input type="radio" bind:group={selectedSize} value={size} />
{size}
</label>
{/each}
</fieldset>
<p>Selected size: {selectedSize}</p>

Transitions

Basic Transitions

<script>
import { fade, fly, slide, scale, blur } from 'svelte/transition';
import { flip } from 'svelte/animate';

let visible = $state(true);
let items = $state(['apple', 'banana', 'cherry']);

function addItem() {
items.unshift('new item ' + Date.now());
}

function removeItem(index) {
items.splice(index, 1);
}
</script>

<!-- fade -->
{#if visible}
<div transition:fade={{ duration: 300 }}>
Fade transition
</div>
{/if}

<!-- fly -->
{#if visible}
<div transition:fly={{ y: -20, duration: 400 }}>
Fly transition
</div>
{/if}

<!-- slide -->
{#if visible}
<div transition:slide>
Slide transition
</div>
{/if}

<!-- Asymmetric in/out -->
{#if visible}
<div in:fly={{ x: -200 }} out:fade>
Asymmetric transition
</div>
{/if}

<button onclick={() => visible = !visible}>Toggle</button>

animate:flip — List Animation

<script>
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';

let items = $state([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' },
{ id: 4, name: 'Grape' },
]);

function remove(id) {
items = items.filter(item => item.id !== id);
}

function shuffle() {
items = [...items].sort(() => Math.random() - 0.5);
}
</script>

<button onclick={shuffle}>Shuffle</button>

<ul>
{#each items as item (item.id)}
<li animate:flip={{ duration: 300 }} transition:fly={{ x: 200 }}>
{item.name}
<button onclick={() => remove(item.id)}>×</button>
</li>
{/each}
</ul>

Practical Example 1: Search Filter List

<!-- SearchFilter.svelte -->
<script>
import { fly } from 'svelte/transition';

const ALL_ITEMS = [
{ id: 1, name: 'Laptop', category: 'Electronics', price: 1200 },
{ id: 2, name: 'iPhone', category: 'Electronics', price: 1500 },
{ id: 3, name: 'Jeans', category: 'Clothing', price: 89 },
{ id: 4, name: 'Sneakers', category: 'Shoes', price: 150 },
{ id: 5, name: 'Backpack', category: 'Bags', price: 75 },
{ id: 6, name: 'Tablet', category: 'Electronics', price: 800 },
{ id: 7, name: 'Hoodie', category: 'Clothing', price: 55 },
{ id: 8, name: 'Boots', category: 'Shoes', price: 220 },
];

const CATEGORIES = ['All', ...new Set(ALL_ITEMS.map(i => i.category))];

let searchQuery = $state('');
let selectedCategory = $state('All');
let sortBy = $state('name');

let filteredItems = $derived.by(() => {
let result = ALL_ITEMS;

if (selectedCategory !== 'All') {
result = result.filter(item => item.category === selectedCategory);
}

if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(item => item.name.toLowerCase().includes(query));
}

return [...result].sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'price-asc') return a.price - b.price;
if (sortBy === 'price-desc') return b.price - a.price;
return 0;
});
});

const formatPrice = (n) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
</script>

<div class="search-page">
<h1>Product Search</h1>

<div class="filters">
<input
bind:value={searchQuery}
placeholder="Search products..."
class="search-input"
/>

<div class="category-btns">
{#each CATEGORIES as cat}
<button
class:active={selectedCategory === cat}
onclick={() => selectedCategory = cat}
>
{cat}
</button>
{/each}
</div>

<select bind:value={sortBy}>
<option value="name">Name</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
</div>

<p class="count">{filteredItems.length} products found</p>

{#if filteredItems.length === 0}
<div class="empty" in:fly={{ y: 20 }}>
<p>No results found.</p>
</div>
{:else}
<div class="grid">
{#each filteredItems as item (item.id)}
<div class="card" in:fly={{ y: 20, duration: 200 }}>
<div class="category-badge">{item.category}</div>
<h3>{item.name}</h3>
<p class="price">{formatPrice(item.price)}</p>
</div>
{/each}
</div>
{/if}
</div>

<style>
.search-page { max-width: 800px; margin: 0 auto; padding: 1rem; }
.filters { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem; }
.search-input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
.category-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.category-btns button { padding: 0.25rem 0.75rem; border: 1px solid #ccc; border-radius: 999px; background: white; cursor: pointer; }
.category-btns button.active { background: #ff3e00; color: white; border-color: #ff3e00; }
.count { color: #666; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; }
.card { border: 1px solid #eee; border-radius: 8px; padding: 1rem; position: relative; }
.category-badge { position: absolute; top: 0.5rem; right: 0.5rem; background: #eee; padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
.price { color: #ff3e00; font-weight: bold; }
.empty { text-align: center; padding: 2rem; color: #999; }
</style>

<!-- ImageGallery.svelte -->
<script>
import { fade, scale } from 'svelte/transition';

const TOPICS = ['nature', 'architecture', 'food', 'travel', 'technology'];

let selectedTopic = $state('nature');
let selectedImage = $state(null);

async function fetchImages(topic) {
await new Promise(resolve => setTimeout(resolve, 500));
return Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
src: `https://picsum.photos/seed/${topic}-${i}/400/300`,
alt: `${topic} image ${i + 1}`,
}));
}

let imagesPromise = $derived(fetchImages(selectedTopic));
</script>

<div class="gallery-page">
<h1>Image Gallery</h1>

<nav>
{#each TOPICS as topic}
<button
class:active={selectedTopic === topic}
onclick={() => selectedTopic = topic}
>
{topic}
</button>
{/each}
</nav>

{#await imagesPromise}
<div class="loading">
{#each Array(12) as _, i}
<div class="skeleton" style="animation-delay: {i * 50}ms"></div>
{/each}
</div>
{:then images}
<div class="grid" in:fade={{ duration: 300 }}>
{#each images as image (image.id)}
<button class="thumb" onclick={() => selectedImage = image}>
<img src={image.src} alt={image.alt} loading="lazy" />
</button>
{/each}
</div>
{:catch error}
<p class="error">Could not load images: {error.message}</p>
{/await}
</div>

{#if selectedImage}
<div
class="lightbox"
transition:fade={{ duration: 200 }}
onclick={() => selectedImage = null}
>
<img
src={selectedImage.src.replace('/400/300', '/1200/900')}
alt={selectedImage.alt}
in:scale={{ duration: 300 }}
/>
<button class="close" onclick={() => selectedImage = null}>×</button>
</div>
{/if}

<style>
nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
nav button { padding: 0.4rem 1rem; border: 1px solid #ccc; border-radius: 999px; background: white; cursor: pointer; }
nav button.active { background: #ff3e00; color: white; border-color: #ff3e00; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.5rem; }
.thumb { border: none; padding: 0; cursor: pointer; overflow: hidden; border-radius: 4px; }
.thumb img { width: 100%; height: 150px; object-fit: cover; transition: transform 0.2s; display: block; }
.thumb:hover img { transform: scale(1.05); }
.loading { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.5rem; }
.skeleton { height: 150px; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.lightbox { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9); display: flex; align-items: center; justify-content: center; z-index: 100; }
.lightbox img { max-width: 90vw; max-height: 90vh; object-fit: contain; }
.close { position: absolute; top: 1rem; right: 1rem; background: white; border: none; width: 36px; height: 36px; border-radius: 50%; font-size: 1.2rem; cursor: pointer; }
</style>

Pro Tips

Tip 1: Event Modifier Replacement (Svelte 5)

<!-- Svelte 4 modifiers -->
<button on:click|stopPropagation|preventDefault={handler}>

<!-- Svelte 5 replacement -->
<button onclick={(e) => { e.stopPropagation(); e.preventDefault(); handler(e); }}>

Tip 2: Custom Transition Functions

<script>
function typewriter(node, { speed = 40 }) {
const text = node.textContent;
const duration = text.length * speed;
return {
duration,
tick: (t) => {
const i = Math.trunc(text.length * t);
node.textContent = text.slice(0, i);
},
};
}
</script>

{#if visible}
<p transition:typewriter={{ speed: 30 }}>
This text appears with a typewriter effect!
</p>
{/if}

Tip 3: \{#each} Performance Optimization

Always use unique, stable values as keys. Using array indices as keys causes problems when items are added or removed.

<!-- Bad -->
{#each items as item, i (i)} <!-- Index is not a good key -->

<!-- Good -->
{#each items as item (item.id)} <!-- Unique ID -->

Summary

SyntaxRole
{#if}...{:else}...{/if}Conditional rendering
{#each}...(key)...{/each}Loop rendering
{#await}...{:then}...{:catch}...{/await}Async handling
{#key value}...{/key}Force re-render
{@const}Local constant in block
{@html}Raw HTML insertion
bind:valueTwo-way data binding
transition:Enter/leave transitions
animate:flipList reorder animation

The next chapter covers global state management with Svelte Stores.

Advertisement