Kanban board mode

Set one board prop and the grid renders its rows as cards in horizontal lanes instead of a table. It is the same <SvGrid>, the same data and columns - only the presentation changes. Lanes are bucketed by a field; dragging a card to another lane tells you to reassign that field on your own data.

The same grid rows bucketed into lanes by a groupBy field, each row a card, with a drag reassigning the field.

The minimum

Point board.groupBy at the field that decides the lane. With nothing else, lanes are derived from the distinct values found in the data (first-seen order), and each row renders as a default card built from your columns.

<SvGrid {data} {columns} board={{ groupBy: 'status' }} />

Ordered lanes, titles, colors, WIP limits

Pass an explicit lanes array to fix the order, give friendly titles, and add an accent color or a work-in-progress limit. A lane id equals the groupBy field value of the cards it holds; a lane with no cards shows a placeholder.

<SvGrid {data} {columns}
  board={{
    groupBy: 'status',
    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' },
    ],
  }} />

The lane header always shows its card count; when the count exceeds wipLimit it is flagged (shown as count/limit). Set enforceWip to make the limit hard - a drag or keyboard move that would push a lane past its limit is rejected and the card snaps back (announced to screen readers).

<SvGrid {data} {columns}
  board={{ groupBy: 'status', enforceWip: true, lanes }} />

Lane summaries (roll-ups)

Pass laneSummary to show a per-lane total in the header - story points, a dollar sum, anything. It receives the lane's cards (the current swimlane's when swimlaneBy is set) and returns a short string. swimlaneSummary does the same for a whole swimlane band.

<SvGrid {data} {columns}
  board={{
    groupBy: 'status',
    laneSummary: (rows) => `${rows.reduce((s, r) => s + r.points, 0)} pts`,
    swimlaneSummary: (rows) => `${rows.reduce((s, r) => s + r.points, 0)} pts`,
  }} />

Search, filter and sort

The board renders the grid's filtered and sorted row model, not the raw data - so column filters, the grid's sort, and the built-in search box all flow through to the lanes. A search box shows above the board by default; set searchable: false to hide it, or searchPlaceholder to change its text.

<SvGrid {data} {columns} sortable
  board={{ groupBy: 'status', searchPlaceholder: 'Find a task...' }} />

Swimlanes

Set swimlaneBy to split the board into horizontal swimlane bands by a second field. Each band shows the full lane set with only its own cards. Dragging a card into a lane under a different band reassigns the swimlane field too - onCardMove then carries fromSwimlane / toSwimlane. Add collapsibleSwimlanes to let users fold a whole band via its header chevron.

<SvGrid {data} {columns}
  board={{
    groupBy: 'status',
    swimlaneBy: 'assignee',
    onCardMove: (e) => {
      e.row.status = e.toLane
      if (e.toSwimlane != null) e.row.assignee = e.toSwimlane
    },
  }} />

Collapsible lanes

Set collapsibleLanes and each lane header gets a chevron that collapses the lane to a slim strip - handy for wide boards.

<SvGrid {data} {columns} board={{ groupBy: 'status', collapsibleLanes: true }} />

Card context menu

Provide cardMenu and right-clicking (or long-pressing) a card opens a context menu. Return the items for that card; the ctx argument gives board-native helpers - moveTo(laneId) and edit() - plus the lane list, so a "Move to" submenu is a one-liner. Items use the same MenuItem shape as SvContextMenu (label / icon / shortcut / children submenus / separator / onSelect).

<SvGrid {data} {columns}
  board={{
    groupBy: 'status',
    cardMenu: (row, ctx) => [
      { label: 'Edit', onSelect: ctx.edit },
      { label: 'Move to', children: ctx.lanes
          .filter((l) => l.id !== row.status)
          .map((l) => ({ label: l.title, onSelect: () => ctx.moveTo(l.id) })) },
      { separator: true },
      { label: 'Delete', onSelect: () => remove(row) },
    ],
  }} />

Lane context menu

Alongside cardMenu, laneMenu puts a menu on a lane header right-click. Its ctx gives lane-native helpers - addCard(title?), toggleCollapse(), plus the lane's cards and collapsed state.

<SvGrid {data} {columns}
  board={{ groupBy: 'status',
    laneMenu: (laneId, ctx) => [
      { label: 'Add card', onSelect: () => ctx.addCard() },
      { label: `Clear (${ctx.cards.length})`, onSelect: () => clearLane(laneId) },
    ] }} />

Facet filters, multi-select, flags & age

A cluster of "power" options, all on the built-in default card:

<SvGrid {data} {columns}
  board={{
    groupBy: 'status',
    facets: ['tags', 'assignee'],
    selectable: true,
    onSelectionChange: (rows) => (count = rows.length),
    flagField: 'blocked',
    ageField: 'created',
  }} />

Hierarchical cards (epics -> stories)

Point childrenField at a row's child rows and each card gains a children count that expands to show its sub-cards inline, each with its own title and groupBy value - the epic -> stories shape.

<SvGrid {data} {columns}
  board={{ groupBy: 'status', childrenField: 'stories' }} />

Reorderable lanes & runtime group-by

Set reorderableLanes to let users drag lane headers to reorder the columns; the order is tracked (and saved with persistKey), and onLaneReorder(orderedIds) fires. Because groupBy is reactive, you can also switch the lane axis at runtime just by changing it - the board re-buckets and resets card positions to the new field automatically. Set onLaneRename and users can double-click a lane title to rename it inline.

<script>
  let groupBy = $state('status') // bind to a Status / Assignee / Priority selector
</script>

<SvGrid {data} {columns}
  board={{ groupBy, reorderableLanes: true, onCardMove: (e) => (row[groupBy] = e.toLane) }} />

Saving the board layout

Pass persistKey and the board saves its layout - card positions, order, per-card edits, and collapsed lanes - to localStorage under that key, and restores it on load. It needs getRowId so the saved keys line up with the right rows after a reload.

<SvGrid {data} {columns}
  getRowId={(r) => String(r.id)}
  board={{ groupBy: 'status', persistKey: 'my-board' }} />

The layout is an overlay on top of your data, so if you also persist the data itself (e.g. mirroring onCardMove to your store), the two stay in sync. To persist somewhere other than localStorage, use onLayoutChange(layout) and write it yourself; feed it back by seeding your data before mount. To reset, clear the key and remount the board (e.g. Svelte's {#key}).

Virtualized lanes (large boards)

For boards with thousands of cards in a lane, set virtualized so each lane only keeps the cards in view in the DOM. Windowing is fixed-height, so set cardHeight to match your cards (default 76). Drag-and-drop, keyboard move, and search all keep working - keyboard focus scrolls an off-screen card into view before landing on it.

<SvGrid {data} {columns}
  board={{ groupBy: 'status', virtualized: true, cardHeight: 72 }} />

Because windowing assumes a uniform card height, keep your cards a consistent size (or close to it) when virtualizing; leave virtualized off for boards with a few hundred cards, where it isn't needed.

Moving cards

Moving cards is built in - you write no move code. Cards are draggable between lanes and within a lane (with a live insertion marker), and the board applies the move itself: it reassigns the lane and reorders, tracked as an overlay keyed by row id, without mutating your data.

onCardMove is an optional notification - it fires after each move so you can persist to a backend, or mirror the new lane onto your own row if another view (a table, a count) reads the same data:

<script>
  // Optional. The board already moved the card; this just syncs your store.
  function onCardMove(e) {
    // e = { row, fromLane, toLane, toIndex }
    e.row.status = e.toLane
  }
</script>

<SvGrid {data} {columns}
  getRowId={(r) => String(r.id)}
  board={{ groupBy: 'status', onCardMove }} />

Pass getRowId so the move overlay stays pinned to the right row across data changes (adds/removes/re-sorts). Without it the board falls back to per-object identity, which is fine until you replace the row objects wholesale.

Keyboard drag-and-drop

The board is fully keyboard operable, so drag-and-drop is not mouse-only. Focus a card and:

Every step is announced through an ARIA live region for screen-reader users.

Editing cards

Set editable and the board gets a built-in card editor: double-click a card (or press F2 while it is focused) to edit its fields inline. The fields come from the columns that declare an editorType (or all fields if none do), rendered as the matching input (text / number / date / checkbox). Save writes to the board's edit overlay - the card updates immediately - and fires onCardCommit with the changed fields, so you can persist:

<script>
  function onCardCommit(e) {
    // e = { row, changes, values } - the board already applied it
    Object.assign(e.row, e.values) // mirror onto your data / persist
  }
</script>

<SvGrid {data} {columns}
  board={{ groupBy: 'status', editable: true, onCardCommit }} />

Detail drawer

For a fuller editor, set drawer and opening a card shows a built-in SvDrawer + SvForm rendered from your columns with the UI-kit editors (text / date / select / number / colour / checkbox, chosen from each column's editorType). true shows all fields; pass a config to pick a subset or customize the drawer:

<SvGrid {data} {columns}
  board={{ groupBy: 'status', drawer: true, onCardCommit: (e) => save(e) }} />

<!-- only some fields, custom title / side -->
<SvGrid {data} {columns}
  board={{ groupBy: 'status',
    drawer: { fields: ['title', 'due', 'assignee'], title: (row) => row.title, side: 'right' } }} />

Saving applies the edit and fires onCardCommit. The drawer does not close on an outside click (so opening a select or picker inside it, or a stray click, never loses your edits) - close it with Save, Escape, or the × button. If commentsField is set, the drawer also shows the comments thread (read / write / delete) below the form.

Precedence: onCardEdit > drawer > the inline editable editor - so pass onCardEdit if you'd rather open your own editor or route to a detail screen.

Custom cards

Omit card for the default card (title from the first column, then a few meta rows). Pass a card snippet to render whatever you like - it receives the row, so you can drop in avatars, chips, progress, or actions from the UI kit.

<SvGrid {data} {columns}
  board={{ groupBy: 'status', card: taskCard }} />

{#snippet taskCard(task)}
  <div class="font-semibold">{task.title}</div>
  <div class="row">
    <SvAvatar name={task.assignee} size={20} />
    <SvChip variant={task.priority === 'High' ? 'danger' : 'neutral'}>
      {task.priority}
    </SvChip>
  </div>
{/snippet}

You can also set titleField and cardFields to steer the default card without writing a snippet.

Adding cards

Provide onCardAdd and each lane header gains a + button; it fires with the lane id so you can append a new row in that lane. Add composer for a nicer type-a-title box at the bottom of each lane - Enter calls onCardAdd(laneId, title).

<SvGrid {data} {columns}
  board={{ groupBy: 'status', composer: true, onCardAdd: (lane, title) => addRow(lane, title) }} />

Card badges, tags, comments & cover

The default card can show a rich badge row without a custom snippet - point the board at your fields:

<SvGrid {data} {columns}
  board={{
    groupBy: 'status',
    labelsField: 'labels',           // tags: string[] or { text, color }[] (coloured chips)
    dueField: 'due',                 // Date | ISO string; red when overdue
    assigneesField: 'assignees',     // string | string[] -> avatar initials
    commentsField: 'comments',       // array (length) or number -> comment-count badge
    attachmentsField: 'attachments', // array or number -> attachment-count badge
    coverField: 'cover',             // a CSS colour -> strip across the card top
  }} />

labelsField is your tags - pass { text, color }[] for coloured chips. These badges render on the default card and below a custom card snippet.

Comments are interactive: the comment badge expands an inline thread, and the same thread appears in the detail drawer (a more discoverable place to manage them). Pass commentsField (a BoardComment[] = { id, text, author?, at? }) plus onCommentAdd / onCommentDelete to let users write and delete comments - update your data in the callbacks.

<SvGrid {data} {columns}
  board={{ groupBy: 'status', commentsField: 'comments',
    onCommentAdd: (row, text) => row.comments.push({ id: uid(), text, author: 'You' }),
    onCommentDelete: (row, id) => (row.comments = row.comments.filter((c) => c.id !== id)) }} />

A blocked card (one that can't move forward - waiting on a dependency) is marked with flagField: a truthy value shows a red corner + a "Blocked" badge, and a string value is used as the reason (badge tooltip). A blocked card is locked from moving (drag + keyboard) until it is unblocked - so blocking actually does something. Set flagBlocksMoves: false if you want a purely visual flag that still allows moves.

Sub-tasks (checklists)

Point subtasks at a field holding BoardSubtask[] ({ id, title, done }) and each card gets a progress chip + bar; expand it to tick items or add more. The board tracks toggles/adds in its own overlay (your data is never mutated) and fires onSubtaskToggle / onSubtaskAdd as notifications. Sub-task state is included in persistKey so it survives a reload.

<SvGrid {data} {columns}
  getRowId={(r) => String(r.id)}
  board={{ groupBy: 'status', subtasks: 'items',
           onSubtaskToggle: (row, id, done) => …, onSubtaskAdd: (row, title) => … }} />

For a full-detail editor, wire onCardEdit to open an SvDrawer with the rest of a card's fields (see demo 349).

BoardConfig

Field Type Notes
groupBy string Required. Field whose value buckets each row into a lane.
lanes BoardLane[] Explicit, ordered lanes. Omit to derive from distinct values.
card Snippet<[row]> Custom card body. Omit for the default card.
onCardMove (e) => void Optional notification after a built-in move: { row, fromLane, toLane, toIndex }. Persist / mirror here.
onCardAdd (laneId, title?) => void Add-card affordance (header +, or composer title).
composer boolean Type-a-title quick-add box at the bottom of each lane.
subtasks string Field of BoardSubtask[]; renders a checklist + progress on the card.
onSubtaskToggle (row, id, done) => void Notified when a sub-task is toggled.
onSubtaskAdd (row, title) => void Notified when a sub-task is added on the card.
labelsField string Field of label strings / {text,color} -> chips on the card.
dueField string Field of a due date -> badge (red when overdue).
assigneesField string Field of assignee name(s) -> avatar(s) on the card.
editable boolean Enables the built-in inline card editor (double-click / F2).
onCardEdit (row) => void Open your own editor instead of the built-in one.
drawer boolean | BoardDrawerConfig Built-in SvDrawer + SvForm detail editor; true = all fields, or { fields, title, side, size }.
cardMenu (row, ctx) => MenuItem[] Card right-click menu; ctx = { lanes, moveTo, edit }.
laneMenu (laneId, ctx) => MenuItem[] Lane-header right-click menu; ctx = { cards, collapsed, toggleCollapse, addCard }.
facets string[] Fields shown as a facet filter-chip bar above the board.
selectable boolean Card multi-select; dragging a selected card moves the whole selection.
onSelectionChange (rows) => void Fires with the selected rows.
flagField string Truthy -> "Blocked" badge + red corner (string = reason); locks the card from moving.
flagBlocksMoves boolean Whether a blocked card is locked from moving. Defaults to true.
ageField string A date -> a relative age badge (3d).
childrenField string Child rows -> an expandable count of nested mini-cards.
onCardCommit (e) => void Fires when the built-in editor saves: { row, changes, values }.
laneSummary (rows, laneId) => string Per-lane header roll-up (points / $ total / count).
swimlaneSummary (rows, swimId) => string Per-swimlane band roll-up.
onLaneRename (laneId, title) => void Enable double-click inline lane rename.
swimlaneBy string Second axis: split the board into swimlane bands by this field.
collapsibleSwimlanes boolean Let users collapse a swimlane band via its header chevron.
commentsField string BoardComment[] -> a comment badge that expands to a thread.
onCommentAdd (row, text) => void Let users write comments (append to your data).
onCommentDelete (row, commentId) => void Let users delete comments.
attachmentsField string Array or number -> attachment-count badge.
coverField string A CSS colour -> a strip across the card top.
enforceWip boolean Reject moves that would push a lane past its wipLimit.
collapsibleLanes boolean Let users collapse a lane to a strip via its header chevron.
reorderableLanes boolean Let users drag lane headers to reorder columns (persisted).
onLaneReorder (orderedIds) => void Fires after a lane drag-reorder.
searchable boolean Show the built-in search box (default true).
searchPlaceholder string Placeholder for the search box.
virtualized boolean Window each lane (only in-view cards in the DOM) for very large boards.
cardHeight number Estimated card height for virtualized windowing (default 76).
persistKey string Save/restore the board layout to localStorage (needs getRowId).
onLayoutChange (layout) => void Fires with the serializable layout on any change.
titleField string Title on the default card (defaults to first column).
cardFields string[] Meta rows on the default card (max 4).

BoardLane = { id, title?, color?, wipLimit? }.

Notes

See also