createKanban
Headless two-level board state for columns of cards, with column reorder, in-column reorder, and a cross-column transfer primitive. Pure logic — no drag-and-drop, no keyboard, no DOM — so consumers can drive it from any input modality.
Usage
createKanban composes createSortable twice — once for the column order and once per column for item order — and links them through a cross-column transfer primitive. Drag-and-drop wiring, keyboard navigation, and rendering are consumer concerns.
import { createKanban } from '@vuetify/v0'
import type { KanbanColumnTicketInput, SortableTicketInput } from '@vuetify/v0'
interface CardInput extends SortableTicketInput {
value: { title: string }
}
interface ColumnInput extends KanbanColumnTicketInput<CardInput> {
value: { title: string }
}
const kanban = createKanban<CardInput, ColumnInput>()
const todo = kanban.columns.register({ value: { title: 'Todo' } })
const done = kanban.columns.register({
value: { title: 'Done' },
accept: (ticket, from, toIndex) => done.items.size < 5,
})
const a = todo.items.register({ value: { title: 'Write spec' } })
todo.items.register({ value: { title: 'Ship docs' } })
// Cross-column transfer: move item a to done column at index 0
kanban.transfer(a.id, done.id, 0)
// Subscribe to cross-column transfer events
kanban.on('transfer:ticket', ({ ticket, from, to, fromIndex, toIndex }) => {
console.log(ticket.value.title, from, '→', to, '@', toIndex)
})Architecture
createKanban orchestrates two tiers of createSortable. Column order and item order are independent sortables; transfer is the only cross-column primitive. An internal lookup registry maps each item id to its owning column id so transfer can resolve the source column without the caller providing it.
The composable adds the following on top of two createSortable instances:
| Addition | Purpose |
|---|---|
columns | A createSortable that owns the column order. Each registered ticket carries a items field — its own inner createSortable. |
column.items | Per-column createSortable, created when the column is registered. Unregistered when the column unregisters. |
transfer(id, toColumnId, toIndex) | Cross-column move. Same-column calls collapse to column.items.move — no transfer:ticket event fires. |
on / off | Subscribe to the transfer:ticket event. For column register/unregister/move events, subscribe via kanban.columns.on(...) instead. |
lookup (internal) | createRegistry mapping item id → column id. Maintained by per-column items-bus subscriptions. |
bus (internal) | createRegistry used as an event bus for transfer:ticket. |
Reactivity
| Property / Method | Reactive | Notes |
|---|---|---|
kanban.columns | Full createSortable — register, move, swap, reorder, on, off | |
column.items | Each column has its own createSortable; wrap with useProxyRegistry for reactive iteration | |
Option: disabled | MaybeRefOrGetter<boolean> — constructor option; gates all cross-column transfers when truthy | |
column.disabled (per-column-ticket option) | MaybeRefOrGetter<boolean> — gates column.items mutations and transfers in/out of that column | |
ticket.disabled (per-item-ticket option) | MaybeRefOrGetter<boolean> — prevents that specific item from being transferred or moved | |
column.accept (per-column-ticket option) | - | Synchronous predicate (ticket, from, toIndex) => boolean. Called before each cross-column transfer into this column. Async predicates log a warning and are treated as reject. |
transfer:ticket event | Fires after a successful cross-column move. Payload: { ticket, from, to, fromIndex, toIndex }. Does not fire for same-column moves. | |
move:ticket on kanban.columns | Fires when a column is reordered via kanban.columns.move, swap, or reorder. Subscribe via kanban.columns.on('move:ticket', ...) — not kanban.on(...). | |
move:ticket on column.items | Fires when an item is reordered within a column. Subscribe via column.items.on('move:ticket', ...). Not fired for cross-column transfers — those emit transfer:ticket instead. |
Examples
Recipes
Disabling moves
Disable flags are layered: root, column, and item. Each gate is checked independently.
Root gate — freezes the entire board. All cross-column transfers no-op. Each column’s inner sortable also respects the root gate, so intra-column reorders are frozen too.
const kanban = createKanban({ disabled: toRef(() => isReadOnly.value) })
// transfer and column.items.move both no-op while isReadOnly is true
kanban.transfer(id, toColumnId, 0)Column gate — freezes one column. Transfers in or out of that column are silently rejected. Item reorders within the column are also frozen.
const archive = kanban.columns.register({
value: { title: 'Archive' },
disabled: true,
})
kanban.transfer(id, archive.id, 0) // no-op — destination is disabled
kanban.transfer(archiveItemId, todo.id, 0) // no-op — source is disabledItem gate — prevents one item from being transferred. Other items in the same column are unaffected.
const pinned = todo.items.register({
value: { title: 'Pinned task' },
disabled: true,
})
kanban.transfer(pinned.id, done.id, 0) // no-op — ticket is disabledAccept predicate (WIP-limit pattern)
Each column ticket can declare an accept function. It runs when an item is being transferred into that column from another column. Return false to silently reject.
The canonical use case is a WIP (work-in-progress) limit: block transfers into a column once it reaches a maximum item count.
const doing = kanban.columns.register({
value: { title: 'Doing' },
accept: (ticket, from, toIndex) => doing.items.size < 3,
})
// Transferring a fourth item into 'doing' silently no-ops
kanban.transfer(id, doing.id, 0)accept receives the item ticket, the source column id, and the intended destination index. It is only called for cross-column transfers — same-column moves collapse to column.items.move and bypass accept entirely.
Async predicates (those that return a Promise or thenable) are not supported. They log a warn and are treated as a rejection.
Subscribing to transfer events
kanban.on('transfer:ticket', cb) subscribes to every cross-column move. The callback receives a KanbanTransferPayload with five fields.
kanban.on('transfer:ticket', ({ ticket, from, to, fromIndex, toIndex }) => {
// Persist the new board state to a backend
persist({ itemId: ticket.id, fromColumn: from, toColumn: to, toIndex })
})Same-column moves (intra-column reorder via kanban.transfer with toColumnId === fromColumnId) collapse to column.items.move and do not emit transfer:ticket. To observe intra-column reorders, subscribe to column.items.on('move:ticket', ...) instead.
Use kanban.off('transfer:ticket', cb) to remove a subscription with the same callback reference.
Reactive iteration
kanban.columns.values() and column.items.values() return non-reactive snapshots — calling them inside a template directly won’t update when the registry changes.
Wrap with useProxyRegistry to get a reactive { keys, values, entries, size } snapshot:
import { useProxyRegistry } from '@vuetify/v0'
// Reactive column list — updates when columns are registered, unregistered, or reordered
const columns = useProxyRegistry(kanban.columns)
// Reactive item list for a specific column — updates on intra-column moves and cross-column transfers
const items = useProxyRegistry(todo.items)In templates, iterate columns.values (not columns.values()) — the proxy exposes values as a reactive array, not a function.
<template>
<div v-for="column in columns.values" :key="column.id">
{{ column.value.title }}
</div>
</template>Note that column.items itself is not reactive at the column level. If a new column registers after the component mounts, the new column’s items instance is attached to the new ticket — the existing columns proxy picks it up automatically via the registry event.
Listening to column and item reorder
Cross-column moves fire transfer:ticket on the kanban bus. Intra-column moves fire move:ticket on the relevant sortable’s own bus.
// Column reorder — fires when kanban.columns.move / swap / reorder is called
kanban.columns.on('move:ticket', ({ ticket, from, to }) => {
console.log('column moved:', ticket.id, from, '→', to)
})
// Item reorder within a column — fires when column.items.move / swap / reorder is called
todo.items.on('move:ticket', ({ ticket, from, to }) => {
console.log('item moved within todo:', ticket.id, from, '→', to)
})Cross-column transfers do NOT fire move:ticket on either column’s items bus. They fire transfer:ticket on the kanban bus after the batch closes. If you need to observe every positional change regardless of whether it crossed a column boundary, subscribe to both event types.
FAQ
kanban.on is a narrow channel for the kanban-level transfer:ticket event only. Registry events live on the columns registry — subscribe via kanban.columns.on('register:ticket', cb) for column lifecycle, or column.items.on('register:ticket', cb) for per-column item lifecycle.
Same-column kanban.transfer(id, fromColumnId, toIndex) collapses to column.items.move(id, toIndex). The intra-column reorder fires move:ticket on that column’s items bus instead. Subscribe to both kanban.on('transfer:ticket', ...) and column.items.on('move:ticket', ...) if you need to observe every positional change.
accept is a cross-column gate. Same-column moves collapse to column.items.move and bypass accept entirely. If you need a veto on intra-column moves, gate the move at the call site or use column.disabled / per-item disabled.
No. Async predicates log a warning and are treated as a rejection. Resolve any async work outside of accept and gate the transfer call yourself.
Use kanban.transfer(id, toColumnId, toIndex) whenever the destination column might differ from the source — it resolves the source column from the internal lookup registry and emits transfer:ticket for cross-column moves. Use column.items.move(id, toIndex) directly when you already know the move is intra-column and want to skip the cross-column gates.
Functions
createKanban
(_options?: KanbanOptions) => KanbanContext<ItemZ, ColZ>Create a headless two-level sortable orchestrator.
Options
disabled
MaybeRefOrGetter<boolean> | undefinedDisabled state for the entire model instance
Default: false
multiple
MaybeRefOrGetter<boolean> | undefinedAllow multiple tickets to be selected simultaneously
Default: false
Properties
columns
SortableContext<ColZ, KanbanColumnTicket<ItemZ, ColZ>>Column registry. Use this for column register / unregister / move / swap / reorder.
on
KanbanEventListener<ItemZ>Subscribe to the kanban-level `transfer:ticket` event, fired after each successful cross-column move. Registry events (`register:ticket`, `unregister:ticket`, `move:ticket`) live on the columns registry — use `kanban.columns.on(...)` for those, or `column.items.on(...)` for per-column intra-column reorder events.
off
KanbanEventListener<ItemZ>Unsubscribe from the `transfer:ticket` event. Must be called with the same callback reference used to subscribe.
Methods
transfer
(id: ID, toColumnId: ID, toIndex: number) => SortableTicket<ItemZ> | undefinedMove an item across columns. Same-column transfer (where `toColumnId` matches the item's current column) collapses to `column.items.move` and does not emit `transfer:ticket`. Returns the moved ticket, or `undefined` when gated (kanban / source / destination / item disabled, destination.accept rejected, unknown id, duplicate destination id).