11 - Build a Todo SPA
Build a Todo SPA with Svelte 5 + Tailwind 4
In this lesson, you'll build a simple Todo SPA using Svelte 5 runes, Tailwind CSS 4, and Vite. This project combines $state, $derived, bind:value, event handlers, and {#each} control flow.
What You'll Build
A functional Todo app with:
- Add, toggle, and delete todos
- Remaining count
- Styled with Tailwind CSS 4
Project Setup
Scaffold a Vite + Svelte 5 + TypeScript project:
npm create vite@latest sv5-todo -- --template svelte-ts
cd sv5-todoInstall Tailwind CSS 4 with the Vite plugin:
npm install -D tailwindcss @tailwindcss/viteInstall all dependencies:
npm installProject Structure
Delete everything inside src/ except main.ts and app.css, then create:
src/
├── main.ts # Entry point, mounts the app
├── app.css # Tailwind import
└── App.svelte # Todo app componentStep 1: Configure Tailwind in Vite
Add the Tailwind CSS Vite plugin:
// vite.config.ts
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [svelte(), tailwindcss()],
})Key concepts:
- Tailwind 4 uses a Vite plugin instead of PostCSS — no
tailwind.config.jsneeded - Just add
tailwindcss()to the plugins array
Step 2: Set Up the CSS
Replace app.css with a single Tailwind import:
/* src/app.css */
@import 'tailwindcss';Key concepts:
- Tailwind 4 uses
@import 'tailwindcss'instead of the old@tailwind base/components/utilitiesdirectives - No config file needed — utility classes work out of the box
Step 3: Set Up the Entry Point
// src/main.ts
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default appKey concepts:
- Svelte 5 uses
mount()instead ofnew App()to create the root component - CSS is imported here so Tailwind styles are available globally
Step 4: Build the Todo App
<!-- src/App.svelte -->
<script lang="ts">
// $state() — Svelte 5 rune that creates reactive state.
// When this value changes, any UI that reads it re-renders automatically.
let todos = $state<{ id: number; text: string; done: boolean }[]>([]);
let newTodo = $state('');
// $derived() — computed value that auto-updates when dependencies change.
let remaining = $derived(todos.filter((t) => !t.done).length);
function addTodo() {
const text = newTodo.trim();
if (!text) return;
// In Svelte 5, $state arrays have fine-grained reactivity —
// .push() triggers updates (no need to reassign the array).
todos.push({ id: Date.now(), text, done: false });
newTodo = '';
}
function removeTodo(id: number) {
todos = todos.filter((t) => t.id !== id);
}
</script>
<!-- Tailwind 4: utility classes work out of the box, no config needed -->
<div class="mx-auto max-w-md p-8">
<h1 class="mb-6 text-3xl font-bold">Todo App</h1>
<!-- Svelte 5: event handlers are lowercase DOM attributes (onsubmit, not on:submit) -->
<form onsubmit={(e) => { e.preventDefault(); addTodo(); }} class="mb-4 flex gap-2">
<!-- bind:value creates two-way binding to $state variable -->
<input
bind:value={newTodo}
placeholder="Add a todo..."
class="flex-1 rounded border px-3 py-2"
/>
<button type="submit" class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Add
</button>
</form>
<ul class="space-y-2">
<!-- {#each} with a key (todo.id) for efficient DOM updates -->
{#each todos as todo (todo.id)}
<li class="flex items-center gap-3 rounded border p-3">
<!-- bind:checked on a $state object property -->
<input type="checkbox" bind:checked={todo.done} class="size-4" />
<span class={todo.done ? 'flex-1 line-through opacity-50' : 'flex-1'}>{todo.text}</span>
<button onclick={() => removeTodo(todo.id)} class="text-red-500 hover:text-red-700">✕</button>
</li>
{/each}
</ul>
{#if todos.length}
<p class="mt-4 text-sm text-gray-500">{remaining} remaining</p>
{/if}
</div>Key concepts:
$state()creates reactive state — the todos array and input text$derived()computes the remaining count, auto-updates when todos changebind:valueandbind:checkedcreate two-way bindings to$statevariablesonsubmit,onclickare lowercase DOM event attributes (Svelte 5 syntax, noton:click).push()on a$statearray triggers reactivity directly (fine-grained reactivity){#each todos as todo (todo.id)}uses a key for efficient DOM reconciliation{#if}conditionally renders the remaining count
Run the App
npm run devSummary
| Rune / Feature | Usage |
|---|---|
$state() |
todos array, input text |
$derived() |
remaining count |
bind:value |
two-way binding on text input |
bind:checked |
two-way binding on checkboxes |
onsubmit, onclick |
Svelte 5 lowercase event handlers |
{#each} with key |
efficient list rendering |
{#if} |
conditional rendering |
Key Takeaways
- Tailwind 4 setup: Use
@tailwindcss/viteplugin +@import 'tailwindcss'— no config file - Reactive state:
$state()for mutable data,$derived()for computed values - Fine-grained reactivity: Array mutations like
.push()work directly on$statearrays - Event handlers: Svelte 5 uses lowercase DOM attributes (
onclicknoton:click) - Two-way binding:
bind:valueandbind:checkedstill work the same as Svelte 4
Next Steps
Try extending the app:
- Add localStorage persistence with
$effect() - Add filtering (all, active, completed) with
$derived() - Split into multiple components using
$props() - Add routing with
svelte-spa-router