Skip to content

Kanban

WxKanban is a board of columns you drag cards between: tasks by status, orders by stage, pages on their way to being published.

Drag a card or a column heading, or focus one and press space.
Backlog3
Rewrite the pricing page
ContentMaria
Collect the photos for the gallery
MediaOleh
Decide on the new domain
AdminKate
In progress1/2
Migrate the blog posts
ContentOleh
On review1
Checkout on a phone
QAMaria

Usage

The board takes columns that carry their own cards. What a card looks like is yours — the card slot gets the card, its column and its index.

vue
<script setup>
const columns = ref([
  { id: 'todo', title: 'To do', items: [{ id: 1, title: 'Rewrite the pricing page' }] },
  { id: 'doing', title: 'In progress', tone: 'primary', limit: 3, items: [] },
  { id: 'done', title: 'Done', tone: 'success', items: [] },
])
</script>

<template>
  <wx-kanban :columns="columns" aria-label="Website tasks" @move="save">
    <template #card="{ card }">
      <wx-entity-card :title="card.title" variant="card" bordered size="sm" />
    </template>
  </wx-kanban>
</template>

A card needs an id and nothing else; everything else on it is yours to read in the slot.

Saving a move

The board moves the card between the arrays it was given — that is what makes it land where it was dropped — and then says what happened:

ts
function save(move) {
  // { card, from: { column, index }, to: { column, index }, via: 'pointer' | 'keyboard' }
  axios.patch(`/tasks/${move.card.id}`, { status: move.to.column, position: move.to.index })
}

to.index is the position within its column, counted from zero, which is what a Laravel ->update(['position' => ...]) wants. If the request fails, move the card back yourself — the board holds no state of its own to roll back.

Work-in-progress limits

limit on a column is the point of a board rather than a decoration: over it the count turns red and the column stops accepting cards, whether they are dragged or moved with the keyboard. Reordering inside a full column still works — it does not make the column any fuller.

vue
<template>
  <wx-kanban :columns="[{ id: 'doing', title: 'In progress', limit: 3, items }]" />
</template>

disabled on a column freezes it both ways; on the board it freezes everything.

What a column can do

The column-actions slot is the end of a heading: a plus, a menu, a filter — whatever this column can be told to do. It sits outside everything the board drags, so pressing a button there never starts a move.

vue
<template>
  <wx-kanban :columns="columns" collapsible reorder-columns column-addable @add-column="create">
    <template #column-actions="{ column }">
      <wx-action type="add" size="sm" title="Add a task" @click="add(column)" />
      <wx-dropdown align="end">
        <template #trigger><wx-action type="more" size="sm" title="Column menu" /></template>
        <wx-dropdown-item icon="trash" tone="danger" @click="clear(column)"
          >Empty it</wx-dropdown-item
        >
      </wx-dropdown>
    </template>
  </wx-kanban>
</template>

collapsible folds a column down to a strip with its name read the long way, which is how a board with a dozen statuses stays legible. Bind v-model:collapsed — an array of column ids — to remember which ones are folded between visits. A folded column holds no cards that can be reached, so it takes none: dragging to it is refused and a card moved with the keyboard passes it by.

vue
<template>
  <wx-kanban v-model:collapsed="folded" :columns="columns" collapsible />
</template>

column-addable puts a column-shaped button after the last column and emits add-column; the default slot replaces that button when the application wants its own.

Reordering the columns

reorder-columns lets a column be dragged by its heading — by the heading only, so a card is still picked up by the card. The new order is written into the columns array you passed and reported as { column, from, to, via }.

Keyboard, with a heading focused: Space picks the column up, move it, Space drops it, Esc puts it back.

The keyboard

SortableJS is a pointer library and has nothing to say to a keyboard, so the board carries its own. Tab to a card, then:

KeyWhat happens
Space / EnterPick the card up, or drop it
Move it up or down its column
Move it to the previous or next column that will take it
EscPut it back where it came from

Every move is read out through a live region, and the focus follows the card, so a board is usable without ever touching a mouse. Keys pressed on a button or a link inside a card are left alone.

On a phone

The columns scroll sideways and the swipe snaps to one at a time below 560px — measured on the board, not on the window, so a board in a narrow panel behaves the same way. A drag starts after a short press rather than immediately, which is what tells it apart from a scroll.

The board is as tall as it is given. Put a height on it and each column scrolls its own cards:

vue
<template>
  <wx-kanban class="board" :columns="columns" />
</template>

<style>
.board {
  height: calc(100vh - 200px);
}
</style>

Props

PropTypeDefaultDescription
columnsKanbanColumn[]Required; each carries its own items
collapsedKanbanId[][]Folded columns; use v-model:collapsed
groupstringBoards sharing a name exchange cards
size'sm' | 'md''md'Padding and gaps
columnWidthnumber | string288A number means pixels
disabledbooleanfalseNothing moves
handlestringCSS selector of the part that drags
addablebooleanfalseAdds a button under each column
addLabelstring'Add a card'Its label
collapsiblebooleanfalseA column can be folded to a strip
reorderColumnsbooleanfalseColumns drag by their heading
columnAddablebooleanfalseA button after the last column
addColumnLabelstring'Add a column'Its label
emptyTextstring'Nothing here yet'Shown in a column with no cards
ariaLabelstringAccessible name for the board

Column: { id, title?, items, limit?, tone?, disabled? }, where tone is default | primary | success | warning | danger | info and colours the rule above the column.

Events: move — a card changed position or column; column-move — a column did; add — the button under a column was pressed; add-column — the one after the last column was.

Slots: card ({ card, column, index }); column-header ({ column, count, overLimit }); column-actions ({ column, collapsed }); column-footer ({ column }); empty ({ column }); default — after the last column, in place of the add-a-column button.

Released under the MIT License.