Skip to main content
Vuetify0 is now in alpha!
Vuetify0 Logo
Theme
Mode
Palettes
Accessibility
Vuetify One
Sign in to Vuetify One

Access premium tools across the Vuetify ecosystem — Bin, Play, Studio, and more.

Not a subscriber? See what's included

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.

ts
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:

AdditionPurpose
columnsA createSortable that owns the column order. Each registered ticket carries a items field — its own inner createSortable.
column.itemsPer-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 / offSubscribe 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 / MethodReactiveNotes
kanban.columnsFull createSortable — register, move, swap, reorder, on, off
column.itemsEach column has its own createSortable; wrap with useProxyRegistry for reactive iteration
Option: disabledMaybeRefOrGetter<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 eventFires after a successful cross-column move. Payload: { ticket, from, to, fromIndex, toIndex }. Does not fire for same-column moves.
move:ticket on kanban.columnsFires 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.itemsFires 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.

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

ts
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 disabled

Item gate — prevents one item from being transferred. Other items in the same column are unaffected.

ts
const pinned = todo.items.register({
  value: { title: 'Pinned task' },
  disabled: true,
})

kanban.transfer(pinned.id, done.id, 0)  // no-op — ticket is disabled

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

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

Warning

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.

ts
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:

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

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

ts
// 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

API Reference

The following API details are for the createKanban composable.

Functions

createKanban

(_options?: KanbanOptions) => KanbanContext<ItemZ, ColZ>

Create a headless two-level sortable orchestrator.

Options

reactive

boolean | undefined

Enable reactive behavior for registry operations

Default: false

disabled

MaybeRefOrGetter<boolean> | undefined

Disabled state for the entire model instance

Default: false

enroll

MaybeRefOrGetter<boolean> | undefined

Auto-select tickets on registration

Default: true

multiple

MaybeRefOrGetter<boolean> | undefined

Allow 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> | undefined

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

Was this page helpful?

Ctrl+/