Board power tools (filters, multi-select, menus)
A facet filter bar (by tag + assignee), card multi-select with bulk move (click / Ctrl-click then drag the group), a blocked flag, a card-age badge, and both card + lane right-click menus - all from board config on the built-in default card. Board / Table switcher. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Kanban). See the SvGrid documentation for the full API.
About this example
Board power tools on the Svelte 5 data grid, all from board config on the built-in card: a facet filter bar by tag and assignee through facets, card multi-select with bulk move through selectable, click or Ctrl-click and drag the group, a blocked flag from flagField that locks the card from moving, a card-age badge from ageField, and right-click menus on cards and lanes through cardMenu and laneMenu. Board and Table switcher included.
A facet filter bar (by tag + assignee), card multi-select with bulk move (click / Ctrl-click, then drag the group), a "blocked" flag, a card-age badge, and both card + lane right-click menus - all from board config on the built-in default card.
Imports, features and API used
Imports: @svgrid/enterprise, @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: title (Task), assignee (Assignee), status (Status)
Frequently asked questions
How does bulk move work?
With selectable on, click or Ctrl-click marks several cards; dragging any selected card moves the whole set to the drop lane, and onCardMove is called for each so your data updates per card.
What does the blocked flag do?
flagField names a truthy field; a blocked card shows a red badge with the field's value as the reason and is locked from drag and keyboard moves while flagBlocksMoves stays true.
How are the menus defined?
cardMenu and laneMenu take item arrays or functions of the card or lane returning items, each with a label and an action; right-click opens them portalled next to the pointer.
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.
Source code (351-kanban-power.svelte)
<script lang="ts">
// Kanban board rendering is an Enterprise feature - register the renderer.
import { enableBoardView } from "@svgrid/enterprise";
enableBoardView();
/**
* 351. Board power tools: filters, multi-select, flags, menus
* ----------------------------------------------------------
* A facet filter bar (by tag + assignee), card multi-select with bulk move
* (click / Ctrl-click, then drag the group), a "blocked" flag, a card-age
* badge, and both card + lane right-click menus - all from `board` config on
* the built-in default card.
*/
import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, type ColumnDef, type BoardCardMoveEvent } from '@svgrid/grid'
type Task = {
id: number
title: string
status: string
tags: string[]
assignee: string
blocked: boolean
created: string
}
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
let view = $state<'board' | 'table'>('board')
let selectedCount = $state(0)
let seq = 100
let rows = $state<Task[]>([
{ id: 1, title: 'Auth refresh tokens', status: 'todo', tags: ['backend'], assignee: 'Sam Rivera', blocked: false, created: '2026-07-10' },
{ id: 2, title: 'Board DnD polish', status: 'in_progress', tags: ['ui'], assignee: 'Lee Park', blocked: true, created: '2026-07-05' },
{ id: 3, title: 'Facet filter bar', status: 'in_progress', tags: ['ui', 'feature'], assignee: 'Sam Rivera', blocked: false, created: '2026-07-18' },
{ id: 4, title: 'Perf: virtual lanes', status: 'review', tags: ['perf'], assignee: 'Ada Cole', blocked: false, created: '2026-06-28' },
{ id: 5, title: 'Docs sweep', status: 'todo', tags: ['docs'], assignee: 'Lee Park', blocked: false, created: '2026-07-20' },
{ id: 6, title: 'Release 1.4', status: 'done', tags: ['release'], assignee: 'Ada Cole', blocked: false, created: '2026-07-01' },
{ id: 7, title: 'Keyboard a11y', status: 'todo', tags: ['ui'], assignee: 'Sam Rivera', blocked: true, created: '2026-07-12' },
])
const columns: ColumnDef<any, Task>[] = [
{ field: 'title', header: 'Task', editorType: 'text' },
{ field: 'assignee', header: 'Assignee', editorType: 'text' },
{ field: 'status', header: 'Status' },
]
let lanes = $state([
{ id: 'todo', title: 'To do' },
{ id: 'in_progress', title: 'In progress', color: '#f59e0b' },
{ id: 'review', title: 'Review', color: '#8b5cf6' },
{ id: 'done', title: 'Done', color: '#22c55e' },
])
function renameLane(laneId: string, title: string) {
const l = lanes.find((x) => x.id === laneId)
if (l) l.title = title
}
function onCardMove(e: BoardCardMoveEvent<Task>) {
e.row.status = e.toLane
}
function cardMenu(row: Task, ctx: { lanes: { id: string; title?: string }[]; moveTo: (id: string) => void }) {
return [
{
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: row.blocked ? 'Unblock' : 'Mark blocked', onSelect: () => (row.blocked = !row.blocked) },
{ separator: true },
{ label: 'Delete', onSelect: () => (rows = rows.filter((r) => r !== row)) },
]
}
function laneMenu(laneId: string, ctx: { cards: Task[]; addCard: (t?: string) => void }) {
return [
{ label: 'Add card', onSelect: () => ctx.addCard('New task') },
{ label: `Clear lane (${ctx.cards.length})`, disabled: ctx.cards.length === 0, onSelect: () => (rows = rows.filter((r) => !ctx.cards.includes(r))) },
]
}
function addCard(laneId: string, title?: string) {
rows = [...rows, { id: ++seq, title: title || 'New task', status: laneId, tags: [], assignee: 'Unassigned', blocked: false, created: new Date().toISOString().slice(0, 10) }]
}
</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="text-sm kb-note">
The same <strong><SvGrid></strong>, two views. Filter by the chips, <strong>click</strong>
/ <kbd>Ctrl</kbd>-click to multi-select then drag the group, <strong>right-click</strong> a card
or a lane header for menus, and <strong>double-click a lane title</strong> to rename.
A <strong>Blocked</strong> card is locked - you can't drag it until you unblock it
(right-click -> Unblock); the small badge is card age.
</div>
<div class="inline-flex rounded-md border overflow-hidden text-sm shrink-0 kb-seg">
<button class="px-3 py-1 kb-seg-btn {view === 'board' ? 'is-on' : ''}"
onclick={() => (view = 'board')}>Board</button>
<button class="px-3 py-1 kb-seg-btn {view === 'table' ? 'is-on' : ''}"
onclick={() => (view = 'table')}>Table</button>
</div>
</div>
<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,
labelsField: 'tags',
assigneesField: 'assignee',
flagField: 'blocked',
ageField: 'created',
facets: ['tags', 'assignee'],
selectable: true,
onSelectionChange: (sel) => (selectedCount = sel.length),
laneSummary: (list) => `${list.length}`,
onCardMove,
onCardAdd: addCard,
onLaneRename: renameLane,
cardMenu,
laneMenu,
}}
/>
{:else}
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
getRowId={(r) => String(r.id)}
sortable
filterable
enableInlineEditing
rowHeight={36}
containerHeight="100%"
fitColumns
/>
{/if}
</div>
<footer class="text-sm shrink-0 kb-note">
{rows.length} tasks · {rows.filter((t) => t.blocked).length} blocked · {selectedCount} selected
</footer>
</section>
<style>
.kb-note { color: var(--sg-muted, #64748b); }
.kb-seg { border-color: var(--sg-border, #cbd5e1); }
.kb-seg-btn {
background: transparent;
color: var(--sg-fg, #0f172a);
}
.kb-seg-btn.is-on {
background: var(--sg-accent, #1e293b);
color: var(--sg-on-accent, #fff);
}
</style>More Kanban examples
- Kanban board mode - The same <SvGrid>, same data + columns, rendered as a Kanban board by setting one `board` prop: rows become cards in horizontal lanes bucketed by a `status` field. Built-in drag-and-drop AND keyboard move (Space + arrows) reassign the lane and reorder; double-click / F2 opens a built-in card editor; WIP limits, add-card, and a rich card snippet (assignee avatar + priority chip). Toggle Board / Table to see it is one grid.
- 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.