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.
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
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)
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>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.
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.