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-todo

Install Tailwind CSS 4 with the Vite plugin:

npm install -D tailwindcss @tailwindcss/vite

Install all dependencies:

npm install

Project 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 component

Step 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.js needed
  • 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/utilities directives
  • 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 app

Key concepts:

  • Svelte 5 uses mount() instead of new 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 change
  • bind:value and bind:checked create two-way bindings to $state variables
  • onsubmit, onclick are lowercase DOM event attributes (Svelte 5 syntax, not on:click)
  • .push() on a $state array 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 dev

Summary

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

  1. Tailwind 4 setup: Use @tailwindcss/vite plugin + @import 'tailwindcss' — no config file
  2. Reactive state: $state() for mutable data, $derived() for computed values
  3. Fine-grained reactivity: Array mutations like .push() work directly on $state arrays
  4. Event handlers: Svelte 5 uses lowercase DOM attributes (onclick not on:click)
  5. Two-way binding: bind:value and bind:checked still 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