Svelte Kanban Board
A fully responsive, drag-and-drop Kanban board component built with Svelte 5 and Tailwind CSS v4. Features vertical stacking on mobile, grid layout on larger screens, customizable column colors, card tags, and accessible ARIA labels. Styles are centralized using tailwind-variants for easy theming and maintainability.
Setup #
- Svelte
<script lang="ts">
import { type KanbanCardType, type KanbanColumnType, KanbanBoard } from 'flowbite-svelte';
</script>Default Kanban Board #
Pass columns array with bindable prop, handle onMove and onAddCard callbacks. Supports drag-and-drop between columns with visual feedback.
- Svelte
<script lang="ts">
import { type KanbanCardType, type KanbanColumnType, KanbanBoard, Heading, P } from "flowbite-svelte";
let columns = $state<KanbanColumnType[]>([
{
id: "todo",
title: "To Do",
color: "#ef4444",
cards: [
{
id: 1,
title: "Design new landing page",
description: "Create mockups for the homepage redesign",
tags: ["design", "urgent"]
},
{
id: 2,
title: "Update documentation",
description: "Add API examples to the docs",
tags: ["docs"]
}
]
},
{
id: "in-progress",
title: "In Progress",
color: "#f59e0b",
cards: [
{
id: 3,
title: "Implement authentication",
description: "Add JWT-based auth system",
tags: ["backend", "security"]
}
]
},
{
id: "review",
title: "Review",
color: "#8b5cf6",
cards: [
{
id: 4,
title: "Code review: Payment flow",
tags: ["review"]
}
]
},
{
id: "done",
title: "Done",
color: "#10b981",
cards: [
{
id: 5,
title: "Setup CI/CD pipeline",
description: "Configure GitHub Actions",
tags: ["devops"]
},
{
id: 6,
title: "Database migration",
tags: ["backend", "completed"]
}
]
}
]);
function handleMove(card: KanbanCardType, from: KanbanColumnType, to: KanbanColumnType) {
console.log(`Moved "${card.title}" from "${from.title}" to "${to.title}"`);
// Here you could make an API call to persist the change
// await fetch('/api/cards/move', { method: 'POST', body: JSON.stringify({ cardId: card.id, fromId: from.id, toId: to.id }) })
}
function handleAddCard(col: KanbanColumnType) {
// Note: Using prompt() for demo - use proper form UI in production
const cardTitle = prompt(`Add a new card to "${col.title}":`);
if (!cardTitle?.trim()) return;
const newCard: KanbanCardType = {
// Note: Using timestamp for demo - use proper ID generation in production
id: Date.now(),
title: cardTitle.trim(),
tags: ["new"]
};
columns = columns.map((column) => (column.id === col.id ? { ...column, cards: [...column.cards, newCard] } : column));
// Here you could make an API call to persist the new card
// await fetch('/api/cards', { method: 'POST', body: JSON.stringify(newCard) })
}
</script>
<div class="bg-gray-100 py-4 md:py-8 dark:bg-gray-800">
<div class="mx-auto max-w-7xl px-2 sm:px-4">
<div class="mb-4 md:mb-6">
<Heading tag="h1" class="text-2xl md:text-3xl">Project Kanban Board</Heading>
<P class="mt-1 text-sm text-gray-600 md:mt-2 md:text-base">Drag cards between columns to update their status</P>
</div>
<KanbanBoard bind:columns onMove={handleMove} onAddCard={handleAddCard} />
<!-- Optional: Show stats -->
<div class="mt-6 grid grid-cols-2 gap-3 md:mt-8 md:grid-cols-4 md:gap-4">
{#each columns as col (col.id)}
<div class="rounded-lg bg-white p-3 shadow-sm md:p-4">
<div class="text-xs text-gray-600 md:text-sm">{col.title}</div>
<div class="mt-1 text-xl font-bold text-gray-900 md:text-2xl">{col.cards.length}</div>
</div>
{/each}
</div>
</div>
</div>Using Modal #
- Svelte
<script lang="ts">
import { type KanbanColumnType, KanbanBoard, Modal, Label, Input, Textarea, Button } from "flowbite-svelte";
let columns = $state<KanbanColumnType[]>([
{
id: "backlog",
title: "Backlog",
color: "#6b7280",
cards: [{ id: 1, title: "Research user feedback", tags: ["research"] }]
},
{
id: "active",
title: "Active Sprint",
color: "#3b82f6",
cards: [{ id: 2, title: "Build feature X", description: "Sprint 24", tags: ["dev", "high-priority"] }]
},
{
id: "done",
title: "Completed",
color: "#22c55e",
cards: []
}
]);
let formModal = $state(false);
let currentColumn = $state<KanbanColumnType | null>(null);
let error = $state("");
function handleMove() {
columns = [...columns];
}
function handleAddCard(col: KanbanColumnType) {
currentColumn = col;
formModal = true;
error = "";
}
function onaction({ action, data }: { action: string; data: FormData }) {
error = "";
if (action === "addCard") {
const title = data.get("title") as string;
const description = data.get("description") as string;
const tagsInput = data.get("tags") as string;
// Validate title
if (!title?.trim()) {
error = "Title is required";
return false;
}
// Parse tags
const tags = tagsInput?.trim()
? tagsInput
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: undefined;
// Add card to column
if (currentColumn) {
columns = columns.map((column) =>
column.id === currentColumn!.id
? {
...column,
cards: [
...column.cards,
{
id: crypto.randomUUID(),
title: title.trim(),
description: description?.trim() || undefined,
tags
}
]
}
: column
);
}
// Reset current column
currentColumn = null;
}
}
</script>
<KanbanBoard
bind:columns
onMove={handleMove}
onAddCard={handleAddCard}
classes={{
column: "dark:bg-gray-800 shadow-lg",
card: "hover:shadow-xl transition-shadow",
cardTitle: "text-blue-600 font-bold",
addButton: "bg-blue-500 hover:bg-blue-600 text-white dark:text-white",
cardTags: "text-gray-900"
}}
/>
<Modal form bind:open={formModal} size="sm" {onaction}>
<div class="flex flex-col space-y-6">
<h3 class="mb-4 text-xl font-medium text-gray-900 dark:text-white">
Add Card to {currentColumn?.title}
</h3>
{#if error}
<Label color="red">{error}</Label>
{/if}
<Label class="space-y-2">
<span>Title *</span>
<Input type="text" name="title" placeholder="Enter card title" required />
</Label>
<Label class="space-y-2">
<span>Description</span>
<Textarea name="description" placeholder="Enter description (optional)" rows={3} class="w-full" />
</Label>
<Label class="space-y-2">
<span>Tags</span>
<Input type="text" name="tags" placeholder="tag1, tag2, tag3" />
<p class="text-sm text-gray-500 dark:text-gray-400">Separate tags with commas</p>
</Label>
<Button type="submit" value="addCard" class="w-full">Add Card</Button>
</div>
</Modal>Using LocalStorage #
- Svelte
<script lang="ts">
import { type KanbanCardType, type KanbanColumnType, KanbanBoard, Button } from "flowbite-svelte";
import { onMount } from "svelte";
const STORAGE_KEY = "my-kanban-board";
let columns = $state<KanbanColumnType[]>([
{ id: "todo", title: "To Do", color: "#ef4444", cards: [] },
{ id: "doing", title: "Doing", color: "#f59e0b", cards: [] },
{ id: "done", title: "Done", color: "#10b981", cards: [] }
]);
// Load from localStorage on mount
onMount(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
columns = JSON.parse(saved);
} catch (e) {
console.error("Failed to load saved board:", e);
}
}
});
// Save to localStorage whenever columns change
$effect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columns));
});
function handleMove(card: KanbanCardType, from: KanbanColumnType, to: KanbanColumnType) {
console.log(`Moved "${card.title}" from "${from.title}" to "${to.title}"`);
}
function handleAddCard(col: KanbanColumnType) {
const title = prompt(`New task for ${col.title}:`);
if (!title?.trim()) return;
columns = columns.map((column) =>
column.id === col.id
? {
...column,
cards: [
...column.cards,
{
id: Date.now(),
title: title.trim(),
tags: ["new"]
}
]
}
: column
);
}
function clearBoard() {
if (confirm("Clear all cards? This cannot be undone.")) {
columns = columns.map((col) => ({ ...col, cards: [] }));
}
}
</script>
<div class="p-4">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold dark:text-white">My Tasks</h1>
<Button onclick={clearBoard}>Clear Board</Button>
</div>
<KanbanBoard
bind:columns
onMove={handleMove}
onAddCard={handleAddCard}
classes={{
column: "dark:bg-gray-800 shadow-lg",
card: "hover:shadow-xl transition-shadow",
cardTitle: "dark:text-white font-bold",
addButton: "bg-primary-500 hover:bg-primary-600 text-white dark:text-white"
}}
/>
</div>With API Integration (Demo Only) #
⚠️ Note: The API endpoints in this example use simple in-memory storage for demonstration purposes. This is not suitable for production as it has limitations:
- Data resets on server restart
- Not safe for concurrent requests
- Doesn’t scale across multiple server instances
For production, use a proper database (PostgreSQL, MongoDB, Supabase, etc.) with proper transaction handling.
- Svelte
<script lang="ts">
import { type KanbanCardType, type KanbanColumnType, KanbanBoard } from "flowbite-svelte";
import { onMount } from "svelte";
let columns = $state<KanbanColumnType[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
const response = await fetch("/api/kanban/columns");
if (!response.ok) throw new Error("Failed to load board");
columns = await response.json();
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error";
} finally {
loading = false;
}
});
async function handleMove(card: KanbanCardType, from: KanbanColumnType, to: KanbanColumnType) {
try {
const response = await fetch("/api/kanban/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cardId: card.id,
fromColumnId: from.id,
toColumnId: to.id
})
});
if (!response.ok) throw new Error("Failed to move card");
} catch (e) {
// Rollback on error
alert("Failed to move card. Please try again.");
// Reload from server
const response = await fetch("/api/kanban/columns");
columns = await response.json();
}
}
async function handleAddCard(col: KanbanColumnType) {
const title = prompt(`Add card to ${col.title}:`);
if (!title?.trim()) return;
try {
const response = await fetch("/api/kanban/cards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
columnId: col.id
})
});
if (!response.ok) throw new Error("Failed to create card");
const newCard = await response.json();
columns = columns.map((column) => (column.id === col.id ? { ...column, cards: [...column.cards, newCard] } : column));
} catch (e) {
alert("Failed to create card. Please try again.");
}
}
</script>
<div class="bg-white p-4 dark:bg-gray-800">
{#if loading}
<div class="flex h-64 items-center justify-center">
<div class="text-gray-600">Loading board...</div>
</div>
{:else if error}
<div class="rounded border border-red-200 bg-red-50 p-4 text-red-800">
Error: {error}
</div>
{:else}
<KanbanBoard bind:columns onMove={handleMove} onAddCard={handleAddCard} />
{/if}
</div>Component data #
The component has the following props, type, and default values. See types page for type information.