Kanban board mode
Turn a Svelte data grid into a Kanban board with one prop. The same <SvGrid> rows and columns render as a table or as lanes bucketed by a status field; dragging a card to another lane fires onCardMove so you update your own data, and WIP limits, an add-card button and a rich card snippet with an avatar and a priority chip complete the board. The board renderer ships in @svgrid/enterprise.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Kanban). See the SvGrid documentation for the full API.
What this example shows
The SAME <SvGrid responsive={true}>, same data + columns, rendered two ways: as a table, or as a Kanban board by setting one `board` prop. Lanes are bucketed by the `status` field; drag a card to another lane and `onCardMove` reassigns that field on your own data (and reorders). WIP limits, an add-card button, and a rich card snippet (assignee avatar + priority chip) round it out.
Imports, features and API used
Imports: @svgrid/enterprise, @svgrid/grid
Table features registered: rowSortingFeature
Columns: title (Task), assignee (Assignee), priority (Priority), points (Points), status (Status)
Source code (343-kanban-board.svelte)
<script lang="ts">
// Kanban board rendering is an Enterprise feature - register the renderer.
import { enableBoardView } from "@svgrid/enterprise";
enableBoardView();
/**
* 343. Kanban board mode
* ----------------------
* The SAME <SvGrid responsive={true}>, same data + columns, rendered two ways: as a table, or
* as a Kanban board by setting one `board` prop. Lanes are bucketed by the
* `status` field; drag a card to another lane and `onCardMove` reassigns
* that field on your own data (and reorders). WIP limits, an add-card
* button, and a rich card snippet (assignee avatar + priority chip) round
* it out.
*/
import {
SvGrid,
SvAvatar,
SvChip,
tableFeatures,
rowSortingFeature,
type ColumnDef,
type BoardCardMoveEvent,
type BoardCardCommitEvent,
} from '@svgrid/grid'
type Task = {
id: number
title: string
status: string
assignee: string
priority: 'Low' | 'Medium' | 'High'
points: number
comments: string[]
attachments: number
}
let seq = 100
let rows = $state<Task[]>([
{ id: 1, title: 'Design the board layout', status: 'backlog', assignee: 'Sam Rivera', priority: 'Medium', points: 3, comments: ['Start from the grid model', 'Agreed'], attachments: 1 },
{ id: 2, title: 'Lane drag + drop', status: 'in_progress', assignee: 'Lee Park', priority: 'High', points: 5, comments: ['Overlay approach works well'], attachments: 0 },
{ id: 3, title: 'Default card template', status: 'in_progress', assignee: 'Sam Rivera', priority: 'Medium', points: 2, comments: [], attachments: 2 },
{ id: 4, title: 'WIP limit styling', status: 'review', assignee: 'Ada Cole', priority: 'Low', points: 1, comments: ['Use the danger token'], attachments: 0 },
{ id: 5, title: 'Docs page', status: 'backlog', assignee: 'Lee Park', priority: 'Low', points: 2, comments: [], attachments: 0 },
{ id: 6, title: 'Ship 1.3.0', status: 'done', assignee: 'Ada Cole', priority: 'High', points: 8, comments: ['Tagged and released', 'Nice work all'], attachments: 3 },
{ id: 7, title: 'Keyboard DnD (a11y)', status: 'backlog', assignee: 'Sam Rivera', priority: 'High', points: 5, comments: ['APG pattern refs added'], attachments: 1 },
])
const features = tableFeatures({ rowSortingFeature })
const columns: ColumnDef<typeof features, Task>[] = [
{ field: 'title', header: 'Task', editorType: 'text', width: 240 },
{ field: 'assignee', header: 'Assignee', editorType: 'text', width: 140 },
{ field: 'priority', header: 'Priority', editorType: 'text', width: 110 },
{ field: 'points', header: 'Points', editorType: 'number', width: 90 },
{ field: 'status', header: 'Status', editorType: 'text', width: 130 },
]
const lanes = [
{ id: 'backlog', title: 'Backlog' },
{ id: 'in_progress', title: 'In progress', color: '#f59e0b', wipLimit: 3 },
{ id: 'review', title: 'Review', color: '#8b5cf6' },
{ id: 'done', title: 'Done', color: '#22c55e' },
]
let view = $state<'board' | 'table'>('board')
let swimlane = $state<'none' | 'assignee'>('none')
const chipVariant = (p: Task['priority']) =>
p === 'High' ? 'danger' : p === 'Medium' ? 'warning' : 'neutral'
// The board does the whole move itself (lane reassign + reorder) - drag-drop
// and keyboard both just work with no code here. `onCardMove` is optional:
// we mirror the new lane onto our own row so the Table view agrees (this is
// also where you'd persist to a backend).
function onCardMove(e: BoardCardMoveEvent<Task>) {
e.row.status = e.toLane
// Dragging across swimlane bands also reassigns the swimlane field.
if (e.toSwimlane != null) e.row.assignee = e.toSwimlane
}
// The board's built-in editor already applied the edit to its overlay; we
// mirror it onto our row so the Table view (and persistence) agree.
function onCardCommit(e: BoardCardCommitEvent<Task>) {
Object.assign(e.row, e.values)
}
function addCard(laneId: string) {
rows = [
...rows,
{ id: ++seq, title: 'New task', status: laneId, assignee: 'Unassigned', priority: 'Medium', points: 1, comments: [], attachments: 0 },
]
}
// Right-click a card for a context menu: edit, a "Move to" submenu of the
// other lanes (board-native ctx.moveTo), set priority, and delete.
function cardMenu(row: Task, ctx: { lanes: { id: string; title?: string }[]; moveTo: (id: string) => void; edit: () => void }) {
return [
{ label: 'Edit', onSelect: () => ctx.edit() },
{
label: 'Move to',
children: ctx.lanes
.filter((l) => l.id !== row.status)
.map((l) => ({ label: l.title ?? l.id, onSelect: () => ctx.moveTo(l.id) })),
},
{
label: 'Priority',
children: (['Low', 'Medium', 'High'] as const).map((p) => ({
label: p,
onSelect: () => (row.priority = p),
})),
},
{ separator: true },
{ label: 'Delete', onSelect: () => (rows = rows.filter((r) => r !== row)) },
]
}
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="flex items-center justify-between gap-3 shrink-0">
<div class="kb-note text-sm">
The same grid, two views. Drag a card, or focus one and use <kbd>Space</kbd>
+ arrows. <strong>Double-click</strong> to edit, <strong>right-click</strong> for a
context menu, search to filter, collapse a lane, and switch on swimlanes.
<strong>In progress</strong> has an enforced WIP limit of 3.
</div>
<div class="kb-switch inline-flex rounded-md overflow-hidden text-sm shrink-0">
<button
class="px-3 py-1 {view === 'board' ? 'kb-on' : 'bg-transparent'}"
onclick={() => (view = 'board')}>Board</button>
<button
class="px-3 py-1 {view === 'table' ? 'kb-on' : 'bg-transparent'}"
onclick={() => (view = 'table')}>Table</button>
</div>
</div>
{#if view === 'board'}
<div class="flex items-center gap-2 text-sm shrink-0">
<span class="kb-muted">Swimlanes:</span>
<div class="kb-switch inline-flex rounded-md overflow-hidden">
<button
class="px-2.5 py-0.5 {swimlane === 'none' ? 'kb-on' : 'bg-transparent'}"
onclick={() => (swimlane = 'none')}>None</button>
<button
class="px-2.5 py-0.5 {swimlane === 'assignee' ? 'kb-on' : 'bg-transparent'}"
onclick={() => (swimlane = 'assignee')}>By assignee</button>
</div>
</div>
{/if}
<div class="flex-1 min-h-0">
{#if view === 'board'}
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
getRowId={(r) => String(r.id)}
containerHeight="100%"
board={{
groupBy: 'status',
lanes,
editable: true,
enforceWip: true,
collapsibleLanes: true,
swimlaneBy: swimlane === 'assignee' ? 'assignee' : undefined,
collapsibleSwimlanes: true,
commentsField: 'comments',
attachmentsField: 'attachments',
onCardMove,
onCardCommit,
onCardAdd: addCard,
cardMenu,
card: taskCard,
}}
/>
{:else}
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
getRowId={(r) => String(r.id)}
sortable
enableInlineEditing
rowHeight={36}
containerHeight="100%"
fitColumns
/>
{/if}
</div>
<footer class="kb-muted text-sm shrink-0">
{rows.length} tasks ยท {rows.reduce((s, r) => s + r.points, 0)} points
</footer>
</section>
{#snippet taskCard(task: Task)}
<div class="flex flex-col gap-2">
<div class="font-semibold text-[0.84rem] leading-snug">{task.title}</div>
<div class="flex items-center justify-between gap-2">
<span class="kb-muted inline-flex items-center gap-1.5 text-[0.74rem]">
<SvAvatar name={task.assignee} size={20} />
{task.assignee}
</span>
<SvChip variant={chipVariant(task.priority)}>{task.priority}</SvChip>
</div>
<div class="kb-muted text-[0.7rem]">{task.points} pts</div>
</div>
{/snippet}
<style>
.kb-note { color: var(--sg-muted, #475569); }
.kb-muted { color: var(--sg-muted, #64748b); }
.kb-switch { border: 1px solid var(--sg-border, #cbd5e1); }
.kb-on {
background: var(--sg-accent, #1e293b);
color: var(--sg-on-accent, #fff);
}
</style>Related documentation
Related articles
- Building a Kanban Board in Svelte 5 (Without a Board Library) - How to turn a Svelte data grid into a drag-and-drop Kanban board with one prop - lanes, WIP limits, swimlanes, keyboard moves, and virtualized lanes for large boards.
Frequently asked questions
How do I build a Kanban board in Svelte 5?
Give <SvGrid> the same data and columns you use for the table and set the board prop with the field to bucket lanes by (status in this demo). Cards are your rows, and the lane order follows that field's values.
What happens when a card is dragged to another lane?
SvGrid raises onCardMove with the card and the target lane. You assign the new status on your own row object (and reorder if you want), so the board never keeps a copy of your data.
Is the Kanban board free?
The grid and its data model are MIT in @svgrid/grid; the board renderer is part of @svgrid/enterprise. It runs without a key while you evaluate, with a small watermark.
More Kanban examples
- Sprint board (swimlanes + WIP) - An agile sprint board: status lanes crossed with a swimlane per assignee, an enforced WIP limit on In progress, and a story-point roll-up in every lane header via board.laneSummary. Drag across a band to reassign the owner; double-click to edit. Collapsible lanes.
- Sales pipeline (deal board) - A CRM deal board: stages as lanes with a live total-value roll-up per stage (board.laneSummary). Rich deal cards; drag to advance a deal, double-click to edit. Moves and edits persist to localStorage via onCardMove / onCardCommit, so a reload restores the board.
- Large board (virtualized) - Thousands of cards per lane kept smooth with board.virtualized - only the cards in view are in the DOM. A slider scales the board from 1k to 50k cards; drag-and-drop and keyboard move still work across the whole window.
- Support triage board - A helpdesk triage board: status lanes crossed with a priority swimlane, a search box front-and-center, and an SLA colour bar on each card driven by ticket age. Drag across a band to re-triage a ticket priority.
- Content calendar - An editorial pipeline: stage lanes crossed with a swimlane per channel. Rich cards carry a cover glyph, author and target date. Drag a piece down the pipeline, or across a band to move it to another channel; double-click to edit.